Article
· Sep 18, 2023 6m read

How to run code at compile time with macros.

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()
}
}

 

Let's now define the macro, in a classic use case we could have done:

#Def1Arg    GetAppParam(%args)  ##class(dc.MyAppParam).Get(%args)

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))

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")

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")
}

}

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") }

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)

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")

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))
}

 

SELECT
ID, Key, Label, Value, ExistsInCode
FROM dc.MyAppParam


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
}
SELECT *
FROM dc.MyAppParam_CompiledParameter()

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
}

}

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

You can find all the code in this article on GitHub repository.

Thanks for reading.
See you soon!

Discussion (0)1
Log in or sign up to continue