Introduction
Code for Part 2 - Don't forget to Unblock before extracting
In Part 1, we introduced the concept of "scriptable business rules" using IronPython. That project consisted primarily of background information and some unit tests that showed the concepts in action.
Here in Part 2, we're going to get more practical and demonstrate a full implementation. Our sample application is a fictitious boat sales company. Here's the UI for the app:
Disclaimers
The purpose of this sample is to show how to use the Aim.Scripting
framework to integrate dynamic business rules into any application. For this article, I've chosen WPF/MVVM as the vehicle to demonstrate the concepts. Where appropriate, I've included information about Caliburn.Micro MVVM, but keep in mind this is not a tutorial for that framework, nor for WPF in general. So, keep the following in mind:
- I'm not a WPF, MVVM, or
Caliburn.Micro
guru. I have a good grasp of many of the concepts, but some things may not be implemented according to absolute best practices for said technologies. - Most of the styling, and some of the custom controls for the app come from a DLL called
Aim.Wpf
. Feel free to use it as you wish - it's pretty solid. I'm especially fond of my PathButton
and FlowPanel
custom controls :). However, don't expect it to remain constant across releases for this article series - it's a work in progress. - The repository storage mechanism used for this sample is not really suitable for a real application. You would most likely want to use a database as a backing store, but I wanted to reduce the number of dependencies for the project, so I opted for a simple binary file repository rather than SQL or NoSQL.
- The
Aim.Xxx
DLLs used in this project are not yet hosted in an open-source repository such as GitHub. I'm working on it, but haven't gotten around to it yet. - Many of the "implementations" that we use for samples are mostly placeholders with some comments about what a real implementation might look like.
EmailingProvider
, IntegrationProvider
, etc. - you shouldn't expect to start a real-time connection to your corporate SAP implementation!
Refresher
At the foundation of business rules are two high-level concepts that any programmer understands: Events and Commands.
Events
Events are "auto-wired" bits of Python code that respond to user interaction or system-level activity. They require the following pieces:
- A script-enabled event handler defined in your .NET code.
- An IronPython module (which is usually just another data model within your system, implementing the
IScriptDefinition
interface) with a Type Key string to identify the types to listen to. - Some IronPython code in that same module with a signature to match the event.
- Business rules event functions always have the signature
onEventName(sender, e)
, where on
(lowercase) is the prefix, EventName
is the name of the .NET event, sender
is the object triggering the event, and e
is the EventArgs
-derived argument expected by the event delegate contract.
Quick Reminder - Script-Enabled Event Handlers
To "script-enable" an event handler, include the Aim.Scripting
DLL in your project. You don't need to import any namespaces, because the EventArgs
extension methods are defined in the System
namespace.
Override script-enabled handler (defined in a class that derives from the class that defines the event):
protected override void OnPropertyChanged( PropertyChangedEventArgs e )
{
e.FireWithScriptListeners( base.OnPropertyChanged, this );
}
Virtual script-enabled handler (defined in the same class as the event):
protected virtual void OnSomeEvent( EventArgs e )
{
e.FireWithScriptListeners( () => SomeEvent, this );
}
Commands
Commands are quite a bit more involved and are composed of user interaction, Python functions, stored records, return values, and display contexts. Commands require the following pieces:
- An IronPython module with command functions that will be triggered by the user. This is the same data model type that is used to define events (
BizRuleDataModel
in this implementation). The only difference is the way we write the Python code.
- While events have a specific signature that is understood by the scripting runtime, commands do not. A command function can take any number or type of arguments, which will be supplied by the user.
- A storable command data model (
BizRuleCmdDataModel
in this implementation) with meta-information such as the command name, display context, active dates, etc. - A method to determine the active display context, aggregate together the applicable commands, and display them to the user.
- An optional return value.
We'll be covering events first in this article. We'll also cover commands, but future articles will go into more depth on that subject, because command implementations are more complex.
Breaking Changes Since Part 1
I've added the ICommandDefinition
interface to the Aim.Scripting
library to represent a command. Previously, extracting active commands from a business rule module was somewhat kludgy, and required a lot of boilerplate code in the UI and composition root. I opted to formalize these commands under an interface
, allowing the script factory to handle some of the work of finding active commands for a module.
Since the concept of commands has been better formalized in the Aim.Scripting
library, it means that the IScriptDefinition interface
needs a way to query for these commands. Consequently, it got slightly more complex to implement, but is still pretty simple.
Here are the listings for IScriptDefinition
and ICommandDefinition
:
using System;
using System.Collections.Generic;
namespace Aim.Scripting
{
public interface IScriptDefinition
{
string GetModuleName();
string GetTypeKey();
string GetScripts();
IEnumerable<ICommandDefinition> GetCommands();
bool IsActive();
int GetRunSequence();
}
public interface ICommandDefinition
{
string GetFunction();
string GetCommandName();
ExecutionContext GetExecutionContext();
bool IsActive();
}
}
In our sample project, these two interface
s are implemented as BizRuleDataModel
and BizRuleCmdDataModel
, respectively. You can look at the code for those to see how simple the interface
implementation really is - every single implementation method of both interface
s is a single line of code, essentially forwarding a value already defined as a stored property of the class.
Our First Dynamic Event Handler
Open up the Read Me - Events business rule (click the Fx button to the upper right - this will open the list of business rules modules. Double-click a line in the grid to open that record).
Uncomment the onInform(...)
handler that is defined, then save the business rule.
Now, navigate to the product list (sailboat icon), and open an existing or create a new product record. Press the Save button. After you save, check out the status bar at the bottom of the screen. It should have printed a message that was supplied by the business rule event handler.
So how did this happen?
- We created a
BizRuleDataModel
with a Type Key that tells it to listen to events of ProductDataModel
instances. - We added an
onInform(sender, e)
Python method which will listen to Inform
events on ProductDataModel
. - We edited a
ProductDataModel
instance, and the repository raised ProductDataModel
's Inform
event. In this case, the event args was passed with the Info
property set to "Saving
".
A More Useful Event Handler
Let's do something more real-world. Promotional codes are often used to give customers discounts based on the text of the code and maybe other information like the type of the product.
Handling promotional codes can be a pain unless you have a lot of built-in types to handle it. You need to:
- Recognize the code
- Have a time span for which the code is valid
- Determine when to apply the code (usually upon saving the record)
- Determine if the product meets the criteria for promotion
- Determine if the customer meets the criteria - perhaps they have a past due account and are not eligible for promotional discounts
- Do the math to determine the discount
- This can be further complicated if the customer has a standard discount
To look at the code for our July Promotion, open the business rule module with that name and navigate to the Scripts tab.
Got it? Ok, once you've looked that over (you don't need to change anything), create a new SaleDataModel
(click the money icon in the main toolbar to show the Sale list). Set the SaleNumber
, Customer
, and PromoCode
properties on the Main tab. Set the PromoCode
to JULY
.
Now, navigate to the Items tab of the sale and add a few items. For each item you add, select a product from the Product
combobox.
Now, keeping your eye on the DiscountPct
column of the sale items list, press the Save button. You should see the number change from a 0 to a 10. (It may change to more than 10 if you happen to have selected a customer
with a standard discount that is more than 10%). You should also see a status message about a successful application of the promotional code.
One other thing you should notice about the July Promotion business rule module: If you look at the ActiveFrom
and ActiveThru
properties, you'll see that it will only be active from 2015-06-01 thru 2015-08-01. Go ahead and set the ActiveThru
property of July Promotion to something like 2015-06-02, and save the business rule. Then, create another sale using a Customer
without a standard discount. You'll see that the DiscountPct
property remains at 0
.
It's recommended that you always supply an active date range for your modules. It should be composed of two nullable DateTime
properties. BizRuleDataModel
defines it like this:
public DateTime? ActiveFrom
{
get
{
return Get( m_ActiveFrom );
}
set
{
Set( ref m_ActiveFrom, value, "ActiveFrom" );
}
}
public DateTime? ActiveThru
{
get
{
return Get( m_ActiveThru );
}
set
{
Set( ref m_ActiveThru, value, "ActiveThru" );
}
}
public bool IsActive
{
get
{
var now = DateTime.Now;
if( ActiveFrom.HasValue && ActiveThru.HasValue )
return ActiveFrom.Value < now && now <= ActiveThru.Value;
if( ActiveFrom.HasValue )
return ActiveFrom.Value < now;
if( ActiveThru.HasValue )
return now <= ActiveThru.Value;
return true;
}
}
Some Other Events
Here are a couple other events that demonstrate other concepts.
A Fire-Once Startup Event
Our sample also includes a scripted handler in the Startup Events module that fires once, upon the AppBootstrapper
raising the Ready
event.
These types of fire-once startup events can be used for logging program usage, setting global settings, etc.
Sending an Email
We've also defined a rule that says to send an email to the big boss (and write a system log message) if a sale exceeds $100,000
. Keep in mind that our EmailingProvider
implementation is just a placeholder.
There are other events defined in other modules. All of the modules have good notes at the beginning of the Python code in the Scripts tab. Please take the time to read through all of them for ideas, how-tos, and best practices.
Moving On to Commands
We're going to move on now to cover commands. We'll first look at the modules and output from the commands that are already defined, then go into the how-to. Commands require a lot more context than events, so we need to show how we got things working.
Open the product list (sailboat icon). To show the Commands Panel, click the list-gear icon in the far upper right of the main toolbar. Commands are context-sensitive, based on the following:
- The Type Key of the associated business rule module.
- The Execution Context of the command.
ExecutionContext
is an enumeration defined in Aim.Scripting
, with the following members:
Any
- show the module commands based only on the Type Key. These commands will show when a list of items is active, or when a single item is active (such as an edit view). Single
- show the module commands based on the Type Key and whether a single record is active, such as is the case in an edit view. List
- show the module commands based on the Type Key and whether multiple records are active, such as is the case in a list view.
- The Type Key for business rule modules is not required for commands. In the case of a blank Type Key, the command will show based solely on the
ExecutionContext
for that command definition.
With the Commands Panel still open, open the sale list (money icon). Notice that the Commands Panel now shows several group boxes with Run buttons inside.
Where did those come from? To get an idea, navigate to the business rule module list (Fx icon). Open the module called Read Me - Commands, and follow the directions in the Scripts tab.
Once you've done that, navigate back to the products list (sailboat icon). You should see something like the following:
Go ahead and click the Run button for our new command. You should see a status bar message that reflects the state of the check box control above the Run button.
Command Results
Executing a command can return a result. This result can be anything. A simple string
message, a DataTable
, an IEnumerable<T>
list...
We need some place to show those results, and that's where the Command Results panel comes in. Navigate back to the sale list. Once there, press the Run button under the command called Month to Date.
You should see a new tab at the bottom showing the results of executing the command. In this case, we returned an IEnumerable<SaleDataModel>
.
To see how we got here, open the Sales Reports business rules module. First look at the Scripts tab, and read the comments and the code in there.
Next, look at the Commands tab to see how the commands are defined. Notice that each one has its Context
property set to List
- that's how we got the commands to show up when the active context is a list of sale records.
Defining Commands
There are two parts to defining a command:
- The command function. This is the Python code that will run when the command is executed.
- The stored command, which references this function, but also adds a lot of metadata such as the human-readable command name, execution context, active from-thru dates, etc. See the previous image or the Sales Reports business rules module for examples.
Defining command functions isn't really that different from definining event handler functions - except that we have a lot more freedom. There are a few things to be aware of, however, when defining a command function in your Python code:
- The command function name must begin with
cmd
(lowercase). For example, cmdMyFirstCommand()
.
- There is actually a good reason for this. When defining the stored commands, we show the available command functions in a
combobox
. Since a business rule module could have any number of functions, some of which are event handlers, some of which are helper functions, etc., we need a way to differentiate those functions that will be attached to stored commands so that we don't clutter up the combobox
selection list with unwanted functions - we especially don't want someone calling an event handler by invoking a command!
- The command function can take any number or type of arguments. Since Python is a dynamically-typed language,
Aim.Scripting
uses some naming convention magic to attempt setting the expected type (at dynamic function construction time) and converting the provided value (at dynamic function execution time). The recognized parameter name prefixes are int
, long
, num
, bool
, date
, dateTime
, time
, str
, and obj
. I'll leave it as an exercise for the reader to divine what the prefixes mean :).
- This naming convention also has some beneficial side effects. If you have a sharp eye, you may have noticed that some of the controls in the Commands Panel render as checkboxes, some as text boxes, some as date pickers, etc. We use the parameter type inference along with a wonderful WPF concept called "template selectors" to achieve this.
- If the parameter prefix is recognized, the remainder of the parameter name will be split along uppercase lines to become the "human-readable" parameter name. For example,
numQtyInMeters
will get a type of double
and a display name of Qty In Meters
.
Defining the stored command is really simple. Once you have a business rule module with some cmdMyCommand()
type functions, you can navigate to the Commands tab and enter a stored command that associates with the command function.
Notice that the combobox under Function
will show all the functions from the module that begin with cmd
.
The Command _target Variable
When you execute a command, the Aim.Scripting
runtime sets a special variable called _target
that is accessible from the running function.
This _target
variable contains the context in which the command was executed - generally a list of objects in the List
context, and a single object in the Single
context. For example, here is an "integration" command that uses the _target
variable as the list of objects that will be "integrated". This is defined in the Sales Integration business rule module:
"""
Simulated integration strategies. See the AppRuleHost.cs
C# class for more details.
In particular, the host.integrate() method shows one way
that you might wire up a Task<T>, send it off to do its
thing, then report back to the UI with status updates.
While it doesn't have a whole lot to do with scripted
business rules as such, the varying ways that things must
be integrated makes a good case for using business rules
to handle some of the variability points.
Note the use of the automatic _target variable here.
Because we set the Type Key of this module to "Sale", when
we run this command from a context of the list of sales,
that list will be what the _target variable is set to.
"""
##
## Send sales information off to our Materials Requirements
## Planning software. Because our MRP system has such a simple
## API (hahHAHahAHhAHAhahhahaHah), we just coded up a quick
## PhonyIntegrator class to do the job.
##
def cmdSendToMrp():
##
## Call our phony local provider, which spins up a Task<T>
## and sends it off to do the work, reporting back on the
## status line for each item integrated. If any items fail,
## the host.integrate method will show an alert box with
## information about the number of successes and failures.
## See AppRuleHost.cs for more information.
##
host.integrate('Phony', _target)
Command Arguments
We've already touched on how Aim.Scripting
uses naming conventions to enable type inference, but you can do more with command arguments. For example, you can provide default values for the parameters using a special comment above the command function. Here is the Python code for the Sales Reports business rule module. Pay special attention to the two special comments above the cmdForYearMonth(...)
function:
"""
Sales list context reports. The output of these will
show up in a new Command Results window at the bottom
of the screen.
Here, we're simply pulling in the repository and using
that as our data source. More likely you would bring
in System.Data and maybe run a custom stored procedure
that's specific to a certain tenant, for example.
Note how we can reference specific record ids in our
code. That would be a huge no-no in your compiled,
baked-in code, but for a business rule it's exactly
what we would expect to do. We can change it in just
a few seconds if the need arises.
This module shows the concept of "parameter defaults".
If you look at the cmdForYearMonth(...) function, you'll
see that it is decorated with two special comments.
These comments can be used to set parameter defaults
when the PyFunction is constructed (and prior to its
actual runtime execution). Notice that for the "value"
part of the comment, we are calling an actual function
that's defined in this module!
There are a couple rules about using a function as the
default parameter value:
1. The function must be defined in this module, or it
must be included via host.include(...)
2. The function must take no arguments, that is it
must be in the form of myFunction(), with nothing
between the parentheses. Think of the signature for
the function as a Func<T>, that is a method that
takes no arguments and returns a value.
3. The function for the default value is run in a C#
try/catch block. If the try fails, the parameter value
will not be set.
"""
import clr
clr.AddReference('System.Data')
clr.AddReference('BizRules.Core')
clr.AddReference('BizRules.DataModels')
from System import DateTime
from System.Data import DataTable
from BizRules import RepositoryProvider
from BizRules.DataModels import SaleDataModel
prevYear = None
prevMonth = None
def allSales():
return RepositoryProvider.Current.GetRepository[SaleDataModel]().GetAll()
##
## Function for param value default
##
def getYear():
##
## If they have already run it, return the value they used before
##
if prevYear:
return prevYear
return DateTime.Now.Year
##
## Function for param value default
##
def getMonth():
##
## If they have already run it, return the value they used before
##
if prevMonth:
return prevMonth
return DateTime.Now.Month
def cmdMonthToDate():
now = DateTime.Now
return cmdForYearMonth(now.Year, now.Month)
##
## This is how we set default parameters for a command.
## Notice that we can even call a function to get the
## default value.
##
# <param name="intYear" value="getYear()" />
# <param name="intMonth" value="getMonth()" />
def cmdForYearMonth(intYear, intMonth):
global prevYear
global prevMonth
prevYear = intYear
prevMonth = intMonth
sales = allSales()
mSales = []
for sale in sales:
dos = sale.DateOfSale
if dos.Year == intYear and dos.Month == intMonth:
mSales.append(sale)
return mSales
##
## Note how we are referring to a specific record id here.
## Not a big deal at all in business rules scripts.
##
def cmdAllWidgetsInc():
sales = allSales()
mSales = []
for sale in sales:
if sale.CustomerId == '36':
mSales.append(sale)
return mSales
The format of the special comment is:
# <param name="parameterName" value="parameterValue" />
The command parameter comment isn't too intelligent yet - for example, it doesn't associate itself with a specific command function, so if you have two command functions in the same module that have the same parameter name, you'll get the results of the first comment. This is still a work in progress - I plan to make it more robust. In the meantime, if you just use different parameter names inside your command function declarations, all should be well.
By the way, you may have wondered "why not just use Python's default argument values?" We could have done it that way, but (as far as I know), you cannot call another Python function to get the default value. The way we define it, since we bypass the normal syntax for Python default arguments, we can implement it however we want - in our case, we've added the ability to make the default get its value by calling another function.
Well, This is Getting Pretty Long
There is much more to cover. We have barely touched on AppRuleHost.cs, for example - our implementation of the DefaultExecuter
class. Read through the code and comments in AppRuleHost
for ideas about how you can make your business rules much simpler by defining a host.do_something(...)
method, so you can write parameterized boilerplate code in C# rather than IronPython.
What to Look For in the Code
If you want to know how the Commands Panel responds to the context and refreshes itself with the list of available and relevant commands, look at the following classes:
BizRules.Client.Wpf.ViewModels.CommandModuleListViewModel
BizRules.Client.Wpf.ViewModels.CommandModuleViewModel
BizRules.Client.Wpf.ViewModels.CommandViewModel
- Pay particular attention to the code in the
Handle(CommandTargetActivatedMessage message)
method defined in CommandModuleListViewModel
. which is an implementation of the Caliburn.Micro.IHandle<T>
interface - one of my favorite interfaces. - Notice how these three VMs combine to form a hierarchy that shows in the Commands Panel view: Modules -> Module -> Commands -> Command -> Parameters -> Parameter.
If you want to know more about extending Aim.Scripting.DefaultExecuter
, study the BizRules.Client.Wpf.AppRuleHost
class. This class has lots of comments about:
- Creating your own
host
methods - Writing to the UI on the UI thread
- Spinning up
Task<T>
and letting it go off and do its thing, while reporting back in real time
If you need to know more about composition root, and where things are set up for scripting integration, look at the following classes:
BizRules.Client.Wpf.AppBootstrapper
- Pay particular attention to the overridden
Configure()
method
BizRules.Client.Wpf.AppScriptProvider
BizRules.Client.Wpf.ViewModels.ShellViewModel
- Look in the shell view model for lots more
IHandle<T>
implementations
See if you can figure out what's going on in Security Events, Admin Customer Reports, and Login Swap business rule modules. I'll leave it as an exercise for the reader.
History
- 2015-06-09: First release