SLX EntityBoundSmartPart lifecycle
Posted By: nicocrm on December 6th, 2007 in Saleslogix
No Gravatar

I added some logging statements to figure out what the lifecycle of user controls implementing SLX smart parts was and how it meshed with the standard ASP.NET events. This is what I came up with. SLX-specific events are in italic – the rest are standard ASP.NET (I skipped a few of the ASP.NET ones).

When the smart part is not displayed (eg an undisplayed dialog), none of the SLX stuff fires, but the standard ASP.NET still does:

  • OnInit
  • OnLoad
  • OnUnload

EntityContext.GetEntity() is available at all time but is of the type that is bound to the main form – not necessarily the same as the smartpart’s.

The first time it is displayed and bound:

  • OnInit
  • OnLoad
  • OnAddEntityBindings
  • OnLoadCurrentEntity starts
  • OnCurrentEntitySet
  • OnLoadCurrentEntity returns
  • MyDialogOpening
  • OnPreRender
  • OnFormBound (which is actually called from OnPreRender)
  • OnUnload

Again, EntityContext.GetEntity() is available but is of the type of the main form until OnCurrentEntitySet.

On postbacks:

  • OnAddEntityBindings
  • OnInit
  • OnLoad Starts
  • OnLoadCurrentEntity starts
  • OnCurrentEntitySet
  • OnLoadCurrentEntity returns
  • OnLoad Returns
  • Control postback events
  • OnPreRender
  • OnFormBound (which is actually called from OnPreRender)
  • OnUnload

EntityContext.GetEntity() is available and of the correct type.

OnFormBound is not a bad place to hook data binding stuff but it only fires from the PreRender which is a bit late sometimes. OnCurrentEntitySet executes before the bound data is saved to the entity (therefore before any SLX property change handler fires) so it is usually not great either.


Slx 7.2 Web does not save (all) state between postbacks
Posted By: nicocrm on December 3rd, 2007 in Saleslogix
No Gravatar

Reminder for me to post on this in the morning… The “EntityStateService” will not save changes to objects that are more than 1 level deep in a relationship. For example: if you have an opportunity, the opportunity has quotes, the quote has line items. You are on an insert form for an opportunity and you let the user add some quotes. Fine. Now you try adding line items to the quotes. Remember the form is bound to an opportunity (a transient object). The changes to the opp are saved. The changes to the quotes are saved. The changes to the line items are not saved. I am not talking about saving to DB – I am talking about saving the state to the session between postbacks.

The code (EntityStateGraphVisitor) uses a “shallow” visitor pattern to persist the changes to the session (I suppose, to avoid ending up saving the entire DB in the session), so this fails to save the changes 2 levels deep.

Update on this: SLX is preparing a system in the next update to allow for saving deeper entities – no details yet on how it will work but hopefully it will help alleviate the above problem.


Unit Testing SLX
Posted By: nicocrm on December 2nd, 2007 in Saleslogix
No Gravatar

While the framework provided by Sage makes it quite convenient to develop the web application, there is little to no documentation on what is needed to run it “stand-alone” as is needed for unit-testing. This post describes a scenario for a simple unit test I want to run against one of the business rules I designed for my entity. In addition to being useful in its own right for writing unit tests, it helped me understand a lot of the inner workings of the framework.

The specification for the business rule is quite simple: if a Salesorder entity associated with an Opportunity has its status changed to Won, the Opportunity should be changed to Won, and all other sales orders on the opportunity need to have their status changed to “Else Won”. Here is the code for the unit test in its unmodified form:

IOpportunity opportunity = EntityFactory.Create<IOpportunity>();
ISalesorder quote = new SalesOrderBuilder().Build(opportunity);
ISalesorder secondQuote = new SalesOrderBuilder().Build(opportunity);

try
{
    opportunity.Save();

    quote.Status = "Won";
    quote.SaveForm();

    Assert.AreEqual(quote.Status, "Won",
        "Quote status should save as Won");
    Assert.AreEqual(opportunity.Status, "Closed - Won",
        "Opportunity status should change to Closed - Won");
    Assert.AreEqual(secondQuote.Status, "Else Won",
        "Other Quote status should save as Else Won");
}
finally
{
    try
    {
        opportunity.Delete();
    }
    catch (Exception x)
    {
        LOG.Warn("Error cleaning up opportunity", x);
    }
}

If we try running the above code it will crash as soon as we attempt to access the EntityFactory, for it requires the whole ApplicationContext to be instantiated and configured. With much trial and error I found out the following:

  • ApplicationContext.Initialize(appId) will initialize the application. appId is an arbitrary string. This returns a WorkItem object, which we can then use through the rest of the tests. It also initializes ApplicationContext.Current, which is good because some methods are hard-wired to use that global instead of the parameters (grrr). In the web client this is done by the AppManagerModule, a registered HttpModule.
  • Each of the pieces of the app that need a configuration file (for example NHibernate and the Dynamic Method invocation piece) will by default read it under All Users\Application Data\…….Configuration\Application\<appId>\… Of course, that doesn’t work for testing, and it can be redirected, but for each module that you want reading its configuration from another spot you will have to write something like this (for NHibernate, in this case):
    ConfigurationManager configManager =
      workItem.Services.Get();
    ReflectionConfigurationTypeInfo typeInfo =
      new ReflectionConfigurationTypeInfo(typeof(HibernateConfiguration));
    typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource);
    configManager.RegisterConfigurationType(typeInfo);
    

    This will cause the code to read the file from Configuration\Application\<appId>\, relative to the current directory. For the web client they have a slightly different strategy. The code that reads the configuration detects when it is run on the web (HttpContext.Current != null) and in that case uses Server.MapPath to resolve the configuration path.

  • At that point you will have to add a bunch of the Saleslogix assemblies to the references for the app. I was half tempted to add the entire web bin folder but I just stuck it out and re-ran the application until I got all assemblies. In addition to the regular list you will need the Sage.Entity.Interfaces and Sage.SalesLogix.Entities assemblies that are specific to the project, and the following SLX assemblies:
    • Castle.DynamicProxy.dll
    • NHibernate.Caches.SysCache.dll
    • Sage.SalesLogix.Activity.dll
    • Sage.SalesLogix.BusinessRules.dll
    • Sage.SalesLogix.NHibernate.dll
    • Sage.SalesLogix.Plugins.dll
    • Sage.SalesLogix.SpeedSearch.dll
  • Saving or querying NHibernate will not work until a “DataService” which can open new connections is defined. I created a “TestDataService” class which reads the connection string defined in app config and serves it. Then, it needs to be registered like so:

    workItem.Services.AddNew(typeof(TestDataService), typeof(IDataService));

  • Some other services may be required by the code inside of the business rules. The following should probably be added:

    workItem.Services.AddNew(typeof(MockUserService), typeof(IUserService));
    workItem.Services.AddNew(typeof(WebUserOptionsService), typeof(IUserOptionsService));

    The MockUserService is defined to hard-code Admin as Userid/Username (note that the username is really the user code, not the user name). The default implementation returns the Windows user name instead. Not sure what the reasoning was there, but anyway, it won’t work.

  • To simulate a databound context we need to emulate what the page does (look at Account.aspx for example) and set up the entity context (which also registers the entity history service if it hasn’t already been done):

    _workItem.Services.AddNew(typeof(EntityFactoryContextService), typeof(IEntityContextService));

  • Problem in NHibernate: connection is closed… Happens in SessionImpl, Cascades.Cascade (inside of DoSave). You can use this.connectionManager.connection.State to debug. I have not yet found the solution to this one, but it only happens when saving so it is not critical for testing. My gut feeling is this happens inside of the ID generator.

Here is my complete (current) TestSetup class, feel free to rip, you may have to adjust a few of the SSSWorld.Common dependencies. I will come back and update this post as needed if I find new things:

using System;
using System.Collections.Generic;
using System.Text;
using NUnit.Framework;
using Sage.Platform.Application;
using Sage.Platform.Configuration;
using Sage.Platform.Data;
using System.Text.RegularExpressions;
using SSSWorld.Common;
using System.IO;
using log4net;
using System.Data.OleDb;
using System.Data;
using Sage.Platform.DynamicMethod;
using Sage.SalesLogix.Security;
using Sage.SalesLogix.Web;
using Sage.SalesLogix;
using Sage.Platform.Security;
using System.Threading;
using System.Security.Principal;
using System.Diagnostics;
using Sage.Platform.Services;

namespace SSSWorld.Cablofil.SlxPlatform.BL.UnitTest
{
    [SetUpFixture]
    public class TestSetup
    {
        private WorkItem _workItem = null;
        private static readonly ILog LOG = LogManager.GetLogger(typeof(TestSetup));

        [SetUp]
        public void SetupTest()
        {
            log4net.Config.XmlConfigurator.Configure();
            //Globals.Initialize();

            try
            {
                _workItem = ApplicationContext.Initialize("Test");
                _workItem.Services.AddNew(typeof(TestDataService), typeof(IDataService));
                ConfigurationManager configManager = _workItem.Services.Get<ConfigurationManager>();
                ReflectionConfigurationTypeInfo typeInfo = new ReflectionConfigurationTypeInfo(typeof(HibernateConfiguration));
                typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource);
                configManager.RegisterConfigurationType(typeInfo);
                typeInfo = new ReflectionConfigurationTypeInfo(typeof(DynamicMethodConfiguration));
                typeInfo.ConfigurationSourceType = typeof(FileConfigurationSource);
                configManager.RegisterConfigurationType(typeInfo);

                //Thread.CurrentPrincipal = new SLXWindowsPrincipal(User.GetById("ADMIN"), WindowsIdentity.GetCurrent());
                _workItem.Services.AddNew(typeof(MockUserService), typeof(IUserService));
                _workItem.Services.AddNew(typeof(WebUserOptionsService), typeof(IUserOptionsService));
                _workItem.Services.AddNew(typeof(EntityFactoryContextService), typeof(IEntityContextService));
            }
            catch (Exception x)
            {
                LOG.Warn("Test setup failed", x);
                throw;
            }
        }

        [TearDown]
        public void TearDown()
        {
            if (_workItem != null)
            {
                try
                {
                    _workItem.Dispose();
                }
                catch (Exception x)
                {
                    LOG.Warn("Error trying to dispose work item", x);
                }
                try
                {
                    ApplicationContext.Shutdown();
                }
                catch (Exception x)
                {
                    LOG.Warn("Error shutting down app context", x);
                }
            }
        }

        public class MockUserService : SLXUserService
        {
            #region IUserService Members

            public override string UserId
            {
                get
                {
                    return "ADMIN";
                }
            }

            public override string UserName
            {
                get
                {
                    return "Admin";
                }
            }

            #endregion
        }

        /// <summary>
        /// This data service accesses the connection string named "Saleslogix" defined in app.config.
        /// Username and password must be specified in the connection string.
        /// </summary>
        public class TestDataService : IDataService
        {
            private OleDbConnection _connection;

            #region IDataService Members

            public string Database
            {
                get
                {
                    Regex rx = new Regex("Initial Catalog= *([^ ;])");
                    Match m = rx.Match(GetConnectionString());
                    if (m.Success)
                        return m.Groups[1].Value;
                    throw new InvalidOperationException("Could not extract database name from connection string");
                }
            }

            public string Server
            {
                get
                {
                    Regex rx = new Regex("Data Source= *([^ ;])");
                    Match m = rx.Match(GetConnectionString());
                    if (m.Success)
                        return m.Groups[1].Value;
                    throw new InvalidOperationException("Could not extract server from connection string");
                }
            }

            public System.Data.IDbConnection GetConnection()
            {
                lock (this)
                {
                    if (this._connection == null)
                    {
                        this._connection = new OleDbConnection(this.GetConnectionString());
                    }
                    else if (this._connection.ConnectionString.CompareTo(this.GetConnectionString()) != 0)
                    {
                        if (this._connection.State == ConnectionState.Open)
                        {
                            this._connection.Close();
                        }
                        this._connection.Dispose();
                        this._connection = new OleDbConnection(this.GetConnectionString());
                    }
                }
                return this._connection;
            }

            public string GetConnectionString()
            {
                return MyConfiguration.Instance.GetConnectionString("Saleslogix");
            }


            #endregion
        }
    }
}

This should be useful also for running external applications accessing the Saleslogix database, for example Windows services. It is not practical yet because of the lack of transaction support but should be in the near future (I plan to come back to this and fix the current issue with saving an entity at that time!)