Check out my new blog at https://shibumiware.blogspot.com

Friday, May 16, 2008

mpFx - Accessing Enterprise Resource Custom Field Values

My previous post demonstrated using mpFx to access an enterprise resource's custom field values.  Shortly after that, I wrote this post where I indicated my displeasure with my own approach.

In particular, accessing a custom field value using the following semantics was troubling:

_ProjectServer.EnterpriseResources.GetCustomFieldValueAsString(customField, customFieldDefinition)

Before making this call, I load the custom field definitions and the resource custom field data, which I pass to GetCustomFieldValueAsString to return the string representation of the value.

Not very pretty.

A few things to note about custom fields:

  1. Fields can be optional.  This means that it is possible that for a given custom field definition and an enterprise resource, a value (or values) may or may not exist
  2. Implied above, more than one value may be defined per resource, per custom field.  This is helpful and supports having list-based custom fields for things like resource skills
  3. A custom field value may be a date, a string, a number, a flag, a cost, or a duration.

Given the factors above, how to improve the semantics of accessing enterprise resource custom field values in mpFx?

First, I want the object model to follow functional lines rather than system or operational lines (see the vision document on CodePlex).  See the following code snippet, which shows the improved call semantics:

ResourceCustomField customField = _ProjectServer.Resources[resourceGuid].ResourceCustomFieldsCollection[customFieldDefinition.MD_PROP_UID];

imageThe class library has several more classes since last time I wrote about it.  See the image to the right. 

Back to the code snippet above.  I reference a EnterpriseResource (named such because of namespace collisions between mpFx and the PSI) through the indexer that expects a resource GUID. 

Each EnterpriseResource has ResourceCustomFieldsCollection associated with it.  I reference a specific custom field by its MD_PROP_UID or its name.  In the end, I have a new ResourceCustomField object called customField.

Under the covers, mpFx does very little except create light-weight collections containing name-value pairs for each custom field and resource.  Loading of the specific values associated with an enterprise resource for any given custom field doesn't happen until a value of a custom field is accessed.  This is in line with the lazy load approach used across the library.

Note that a call to PSI's ReadResource is required to read resource custom field values from the server.  Unfortunately, and unless I am missing something, there isn't a way to read just the resource's custom field values through the use of a filter; or read just specific custom field values--it is all or nothing. 

Let's take a closer look at mpFx's ResourceCustomField class.

imageThe mpFx ResourceCustomField Class

Custom fields can be defined on projects, tasks, and resources.  The mpFx class library is highly experimental at this stage so expect the implementation to change but the call semantics will remain similar (don't hold me to anything until I release version 1.0 on CodePlex!).   Because custom fields across all entity types are very similar, I am planning on reimplementing ResourceCustomField to use abstract classes (not an interface, because much of the actual implementation can be reused).

Let's take a look at its experimental implementation.  Take a look at the class diagram to the right.  The properties are:

  • Guid - This is the MD_PROP_UID of the custom field
  • Name - The display name of the custom field.  In the code snippet above, the value of "Expertise" is what I am concerned with
  • Type - The type of the field the custom field (text, number, flag, duration, etc)
  • IsList - Because Microsoft Project supports multi-valued lists, I must have a way of indicating to the caller whether the value is a single value or a list.
  • Value - The value of the custom field.  It can either be a single value or a list.

So, let's take a look at a full sample:

ResourceCustomField customField = _ProjectServer.Resources[resourceGuid].ResourceCustomFieldsCollection[customFieldDefinition.MD_PROP_UID];

if (customField == null)
{
    MessageBox.Show(this, "Field not defined for this resource.");
}
else
{
    if (customField.Type != CustomFieldValueType.None)
    {
        if (customField.IsList)
        {
            foreach (object value in customField.List)
            {
                /*  Do something with each value  */
            }
        }
        else
        {
            /*  Do something with the value  */
        }
    }
}

If the value comes back null, I know that the resource does not have values defined for the custom field I asked for.

I check to ensure that the Type is not "None" just to be safe.

I check to see if the value is a list, and if so I can enumerate the values and do something with them.

If the list is a single value, I can do something with it.

Pretty simple.

The custom field values for a specific resource are not loaded until the resource custom fields collection is accessed.  It would be better if I could load just the values the caller is interested in but again, this is not supported by the PSI.    Reading the resource from the PSI looks like this:

public void LoadResourceCustomFields()
{
    using (ResourceDataSet resourceDataSet = _Parent.ResourcesWebService.ReadResource(_Guid))
    {
        Debug.Assert(resourceDataSet != null);

        if (resourceDataSet.Resources.Rows.Count == 0)
        {
            throw new InvalidOperationException( /*  TODO: Exception Message */);
        }

        Debug.Assert(resourceDataSet.Resources.Rows.Count == 1);

        _CustomFields = resourceDataSet.ResourceCustomFields;
    }
}

You might have noticed the LoadResourceCustomFieldValue in the ResourceCustomField class diagram.  That method looks like this:

private void LoadResourceCustomFieldValue()
{
    List<object> values = new List<object>();
    CustomFieldValueType type = CustomFieldValueType.None;

    foreach (ResourceDataSet.ResourceCustomFieldsRow customField in _Parent.Parent.CustomFields.Rows)
    {
        if (customField.MD_PROP_UID == _Guid)
        {
            object value;

            if (TryGetFieldValueInformation(customField, CustomFields.CustomFields.FindByMD_PROP_UID(_Guid), out value, out type))
            {
                values.Add(value);
            }
        }
    }

    if (values.Count > 1)
    {
        _Value = values;
    }
    else
    {
        if (values.Count == 1)
        {
            _Value = values[0];
            _ValueType = type;
        }
        else
        {
            Debug.Assert(values.Count == 0);

            _Value = null;
            _ValueType = CustomFieldValueType.None;
        }
    }
}

TryGetFieldValueInformation() is where the actual value is read from the resource custom fields dataset.  It does the work of either reading the value directly, reading it from the cached lookup table values, or if the cache doesn't contain the value, a call to the PSI is made to get it.  It looks like this (NOTE THAT NOT ALL DATA TYPES ARE IMPLEMENTED YET):

public bool TryGetFieldValueInformation(ResourceDataSet.ResourceCustomFieldsRow customField,
                                        CustomFieldDataSet.CustomFieldsRow customFieldDefinition, 
                                        out object value, 
                                        out CustomFieldValueType type) {

    PSDataType dataType = (PSDataType) customField.FIELD_TYPE_ENUM;

    type = CustomFieldValueType.None;
    value = null;

    switch (dataType)
    {
        case PSDataType.STRING:
            if (customField.IsTEXT_VALUENull() == false)
            {
                value = customField.TEXT_VALUE;
                type = CustomFieldValueType.Text;
            }
            else if (customField.IsCODE_VALUENull() == false)
            {
                value = _Parent.EnterpriseLookupTables.GetLookupTableValue(customFieldDefinition.MD_LOOKUP_TABLE_UID, customField.CODE_VALUE, 1033);
                type = CustomFieldValueType.Text;
            }
            break;
        case PSDataType.NUMBER:
            value = customField.NUM_VALUE.ToString();
            type = CustomFieldValueType.Number;
            break;
        case PSDataType.DATE:
            value = customField.DATE_VALUE.ToUniversalTime().ToLongDateString();
            type = CustomFieldValueType.Date;
            break;
        default:
            break;
    }

    return type != CustomFieldValueType.None;
}

The call to GetLookupTable value will look to the cached lookup tables for the value first and if it doesn't exist, a call is made to the PSI to retrieve it.  The method looks like this:

public string GetLookupTableValue(Guid table, Guid value, int languageCode)
{
    using (LookupTableMultiLangDataSet.LookupTableValuesDataTable lookupTableValues =
                LoadLookupTableValues(table, LookupTableFilters.ItemBasicInformation(table)))
    {
        if (lookupTableValues == null)
        {
            throw new ArgumentNullException( /*  TODO: Exception Message */);
        }

        LookupTableMultiLangDataSet.LookupTableValuesRow valuesTable = lookupTableValues.FindByLT_STRUCT_UIDLCID(value, languageCode);

        if (valuesTable == null)
        {
            throw new InvalidOperationException( /*  TODO: Exception Message */);
        }
        else
        {
            return valuesTable.LT_VALUE_TEXT;
        }
    }
}

The "using" construct above makes a call to LoadLookupTableValues, which does the actual work to get the data from the cache or ask the service for it.  The method looks like this:

public LookupTableMultiLangDataSet.LookupTableValuesDataTable LoadLookupTableValues(Guid table,
                                                                                    string columnsFilter)
{
    if (_CacheLookupTables)
    {
        if (_MultiLanguageLookupTableValues.ContainsKey(table))
        {
            return _MultiLanguageLookupTableValues[table];
        }
    }

    LookupTableMultiLangDataSet.LookupTableValuesDataTable valuesTable =
        GetLookupTableValues(columnsFilter, false);

    if (_CacheLookupTables)
    {
        _MultiLanguageLookupTableValues.Add(table, valuesTable);
    }

    return valuesTable;
}

If the lookup table values are not found in the cache, GetLookupTableValues is called.  It looks like this:

public LookupTableMultiLangDataSet.LookupTableValuesDataTable GetLookupTableValues(string xmlFilter,
                                                                                   bool autoCheckout)
{
    return GetLookupTables(xmlFilter, autoCheckout).LookupTableValues;
}

And finally, the PSI method is called:

public LookupTableMultiLangDataSet GetLookupTables(string xmlFilter, bool autoCheckout)
{
    return _Parent.LookupTablesWebService.ReadLookupTablesMultiLang(xmlFilter, autoCheckout);
}

Here is what the results look like in the test harness:

image

More this weekend!

No comments :

Disclaimer

Content on this site is provided "AS IS" with no warranties and confers no rights. Additionally, all content on this site is my own personal opinion and does not represent my employer's view in any way.