Hello developers,
In this article, I'll show you how to run code at compile time with ObjectScript macros.
Here's a use case that recently led me to use this feature:
As part of a medical application developed for more than 20 years, we have a large number of parameters. Although we have procedures for documenting these settings, it can be helpful to have a quick view of which settings are actually used by the application code.
To get this view, we could search the code with regular expressions. However, it would be more convenient to have all the parameters used directly in a table after compilation is complete.
Let's start by creating a simple persistent class to store the settings:
Class dc.MyAppParam Extends %Persistent
{
Property Label As %String(MAXLEN = "");
Property Key As %String(MAXLEN = 256) [ Required ];
Property Value As %String(MAXLEN = "");
Index UniqueI On Key [ Unique ];
ClassMethod Get(
Key As %String,
DefaultValue As %String = "") As %String
{
Quit:'..UniqueIExists(Key, .id) DefaultValue
Quit ..ValueGetStored(id)
}
ClassMethod Set(
Key As %String,
Value As %String,
Label As %String = "") As %Status
{
If '..UniqueIExists(Key, .id) {
Set param = ..%New(), param.Key = Key, param.Label = Label, param.Value = Value
Quit param.%Save()
}
Set param = ..%OpenId(id), param.Value = Value
Set:Label'="" param.Label = Label
Quit param.%Save()
}
}
ObjectScriptObjectScript
Let's now define the macro, in a classic use case we could have done:
#Def1Arg GetAppParam(%args) ##class(dc.MyAppParam).Get(%args)
ObjectScriptObjectScript
In our case, we don't just want to replace $$$GetAppParam with the method call, but also execute code, so we will use:
ROUTINE MyAppParam [Type=INC]
#Def1Arg GetAppParam(%args) ##expression($$Get^OnCompileParam(%args))
ObjectScriptObjectScript
As we can see the macro calls Get^OnCompileParam ; now let's implement this routine:
ROUTINE OnCompileParam
Get(Key,DefaultValue="")
New (Key,DefaultValue)
Set expression = "##expression(""##class(dc.MyAppParam).Get(""""%1"""",""""%1"""")"")"
For replaceStr = Key, DefaultValue Set expression = $Replace(expression, "%1", replaceStr, , 1)
Set routineDBRef = "^^"_$$GetDBRoutines()
Lock +^[routineDBRef]MyAppParam(Key)
If '$Data(^[routineDBRef]MyAppParam(Key), data) {
Set ^[routineDBRef]MyAppParam(Key) = $ListBuild(Key,DefaultValue,1)
Lock -^[routineDBRef]MyAppParam(Key)
Quit expression
}
Set $List(data,2) = DefaultValue, $List(data,3) = 1 + $ListGet(data,3)
Set ^[routineDBRef]MyAppParam(Key) = data
Lock -^[routineDBRef]MyAppParam(Key)
Quit expression
GetDBRoutines()
New
New $NAMESPACE Set ns = $Namespace, $Namespace = "%SYS"
Do ##class(Config.Namespaces).Get(ns,.pNs), ##class(Config.Databases).Get(pNs("Routines"),.pDb)
Set $Namespace = ns
Quit pDb("Directory")
ObjectScriptObjectScript
The routine performs a set of MyAppParam(Key) in the codebase for each use of the $$GetAppParam macro at compile time and also returns an ##expression with the code to execute at runtime.
Example of use:
Include MyAppParam
Class dc.ParamUsage
{
ClassMethod TestGetAppParam() As %String
{
Set x = $$$GetAppParam("test.key", "DefaultValue")
Set x = $$$GetAppParam("test.key2", "DefaultValue")
}
}
ObjectScriptObjectScript
After compilation, we find the replacement of $$$GetAppParam by calls to the Get method:
ROUTINE dc.ParamUsage.1 [Type=INT,Generated]
;dc.ParamUsage.1
;Generated for class dc.ParamUsage. Do NOT edit. 08/07/2023 10:33:17PM
;;59356D64;dc.ParamUsage
;
TestGetAppParam() methodimpl {
Set x = ##class(dc.MyAppParam).Get("test.key","DefaultValue")
Set x = ##class(dc.MyAppParam).Get("test.key2","DefaultValue") }
ObjectScriptObjectScript
The set is also present in the code base:
IRISAPP>zw ^["^^"_$$GetDBRoutines^OnCompileParam()]MyAppParam
^["^^/usr/irissys/mgr/irisapp/data/"]MyAppParam("test.key")=$lb("test.key","DefaultValue",1)
^["^^/usr/irissys/mgr/irisapp/data/"]MyAppParam("test.key2")=$lb("test.key2","DefaultValue",1)
ObjectScriptObjectScript
Now perform at least two "Set" parameters in the dc.MyAppParam table, this will be useful later:
Do ##class(dc.MyAppParam).Set("test.key", "value parameter", "For testing $$$GetAppParam")
Do ##class(dc.MyAppParam).Set("test.unused", "an unused param", "For testing unused param")
ObjectScriptObjectScript
Over time, developments, improvements, etc. may result in database parameters that are no longer used in the application code.
With the system in place, it is quite easy to check if a database parameter is still used in the code. For example, it would be enough to add a calculated Boolean property in the dc.MyAppParam class:
Property ExistsInCode As %Boolean [ Calculated, SqlComputeCode = { Set {*} = ''$Data(^["^^"_$$GetDBRoutines^OnCompileParam()]MyAppParam({Key}))}, SqlComputed, Transient ];
Method ExistsInCodeGet() As %Boolean [ CodeMode = expression ]
{
''$Data(^["^^"_$$GetDBRoutines^OnCompileParam()]MyAppParam(..Key))
}
ObjectScriptObjectScript
SELECT
ID, Key, Label, Value, ExistsInCode
FROM dc.MyAppParam
SQLSQL
We can also do the opposite, list all the parameters used in the code and check if they are defined in the configuration table. This will, however, require writing a custom class query, but nothing very complicated:
Query CompiledParameter() As %Query(ROWSPEC = "Key:%String,DefaultValue:%String,ExistsInParamTable:%Boolean") [ SqlProc ]
{
}
ClassMethod CompiledParameterExecute(
ByRef qHandle As %Binary,
Filter As %DynamicObject) As %Status
{
Set qHandle("Key") = "", qHandle("dbref") = "^^"_$$GetDBRoutines^OnCompileParam()
Quit $$$OK
}
ClassMethod CompiledParameterFetch(
ByRef qHandle As %Binary,
ByRef Row As %List,
ByRef AtEnd As %Boolean) As %Status [ PlaceAfter = CompiledParameterExecute ]
{
Set qHandle("Key") = $Order(^[qHandle("dbref")]MyAppParam(qHandle("Key")), 1, data)
If qHandle("Key") = "" Set AtEnd = $$$YES, Row = "" Quit $$$OK
Set Row = $Lb($Lg(data,1),$Lg(data,2),..UniqueIExists(qHandle("Key"))), AtEnd = $$$NO
Quit $$$OK
}
ClassMethod CompiledParameterClose(ByRef qHandle As %Binary) As %Status [ PlaceAfter = CompiledParameterExecute ]
{
Kill qHandle Quit $$$OK
}
ObjectScriptObjectScript
SELECT *
FROM dc.MyAppParam_CompiledParameter()
SQLSQL
And there you have it, the objective is now achieved!
However, there are some limitations to this solution.
Since the code is executed at compile time, you can only use string constants. It is therefore impossible to use variables as parameters of $$$GetAppParam.
The OnCompileParam.mac routine must be compiled before classes (and other routines) that use it. Depending on the case, it may be better to compile all classes before routines and this can be problematic here. To solve this problem, I offer you a tip which consists of using a projection and a class in System = 3:
Class dc.Priority [ System = 3 ]
{
Projection compilePriority As dc.Projection;
}
Class dc.Projection Extends %Projection.AbstractProjection [ System = 3 ]
{
ClassMethod CreateProjection(
classname As %String,
ByRef parameters As %String,
modified As %String,
qstruct) As %Status
{
Set routine = "OnCompileParam.mac"
Do:##class(%Library.Routine).Exists(routine) ##class(%Library.Routine).CompileList(routine,"c-d")
QUIT $$$OK
}
}
ObjectScriptObjectScript
The System = 3 keyword places the class in high priority for compilation and the projection it contains will force the routine to compile.
One last important point to note is cleaning up the global MyAppParam.
In the case where the build of your application is always done on a new code base, there is no problem. However, if the build is done on the same code base, it is necessary to empty the global MyAppParam before recompiling.
To do this, you can simply use the Kill command:
Kill ^["^^<path_routine_db>"]MyAppParam
ObjectScriptObjectScript
You can find all the code in this article on GitHub repository.
Thanks for reading.
See you soon!