Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
042462b
feat: implement recursive placeholder resolution in Default parameters
AshokThangavel Dec 30, 2025
0ea3b84
fix: update method name
AshokThangavel Dec 30, 2025
d31ed58
docs: update the CHANGELOG file
AshokThangavel Dec 30, 2025
40e6cb5
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Jan 20, 2026
f8c629b
fix: updated the code based on feedback
AshokThangavel Jan 22, 2026
c921fd7
refactor: Updated placeholder logic
AshokThangavel Jan 22, 2026
7272092
fix:updated the code based on feedback
AshokThangavel Jan 22, 2026
ab6ef64
refactor: implement robust variable resolution using %EvaluateSystemE…
AshokThangavel Jan 23, 2026
725f093
fix: code updated based on comments
AshokThangavel Feb 3, 2026
9328a2a
Merge remote-tracking branch 'origin/main' into feat/unified-variable…
AshokThangavel Feb 10, 2026
16d9e84
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Mar 8, 2026
efc2744
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Mar 8, 2026
26e2d84
docs: updated changelog
AshokThangavel Mar 8, 2026
e7e81f0
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Mar 9, 2026
b19390d
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Mar 10, 2026
94daff4
Merge branch 'feat/unified-variable-interpolation' of https://github.…
AshokThangavel Mar 10, 2026
390aa57
Merge branch 'main' into feat/unified-variable-interpolation
AshokThangavel Mar 19, 2026
aee802e
Merge branch 'main' into feat/unified-variable-interpolation
isc-dchui May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #992: Implement automatic history purge logic
- #973: Enables CORS and JWT configuration for WebApplications in module.xml
- #1110: Add `iriscli` and `ipm` container utility scripts that are auto-installed to `~/.local/bin/` and `~/bin/` so they work both inside and outside of containers (Unix/Linux only)
- #1013: Implement recursive placeholder resolution in Default parameters

### Fixed
- #1001: The `unmap` and `enable` commands will now only activate CPF merge once after all namespaces have been configured instead after every namespace
Expand Down
1 change: 1 addition & 0 deletions src/cls/IPM/Storage/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -1815,6 +1815,7 @@ Method %Evaluate(
set customParams("verbose") = +$get(pParams("Verbose"))
set customParams("ipmDir") = ##class(%Library.File).NormalizeDirectory($system.Util.DataDirectory() _ "ipm/" _ ..Name _ "/" _ ..VersionString)
set tAttrValue = ##class(%IPM.Utils.Module).%EvaluateMacro(tAttrValue)
do ##class(%IPM.Storage.ModuleSetting.Default).ResolvePlaceholders(.customParams)
set tAttrValue = ##class(%IPM.Storage.ModuleSetting.Default).EvaluateAttribute(tAttrValue,.customParams)
set attrValue = ##class(%IPM.Utils.Module).%EvaluateSystemExpression(tAttrValue)

Expand Down
59 changes: 59 additions & 0 deletions src/cls/IPM/Storage/ModuleSetting/Default.cls
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,65 @@ ClassMethod EvaluateAttribute(
return attribute
}

/// Processes configuration values to resolve variable references.
/// Searches for placeholders using the ${var} or {$var} syntax and replaces them with
/// the value of the corresponding 'Default' or 'Resource' name.
ClassMethod ResolvePlaceholders(ByRef customParams)
Comment thread
isc-dchui marked this conversation as resolved.
{
set found = 1
// maxLevels is a safety guard to prevents infinite loops caused by circular references.
set maxLevels = 20
Comment thread
isc-dchui marked this conversation as resolved.

while (found && (maxLevels > 0)) {
set found = 0
//Decrement levels and check for circular references
if (maxLevels <= 0) {
$$$ThrowOnError($$$ERROR($$$GeneralError,"Circular reference or too many levels in placeholders"))
}
set maxLevels = maxLevels - 1
set param = ""
for {
set param = $order(customParams(param), 1, data)
quit:param=""
//Skip if no placeholders remain
continue:data'["{"

set initialData = data
Comment thread
AshokThangavel marked this conversation as resolved.
for delimiter = "${", "{$" {
continue:data'[delimiter
kill seen
set pCount = $length(data, delimiter)
for i=2:1:pCount {
set chunk = $piece(data, delimiter, i)
set key = $piece(chunk, "}", 1)
continue:key=""
continue:$data(seen(key))
set seen(key) = ""

// Evaluate the placeholders.
// Perform a module-level lookup: check if local custom parameters are defined first;
// if not found, attempt to resolve as a system expression.
set search = delimiter _ key _ "}"
if $data(customParams(key), val) {
set data = $replace(data, search, val)
} else {
// If not found locally, attempt to resolve as an IPM system expression.
// This returns the resolved value or the original string if no match is found.
set resolved = ##class(%IPM.Utils.Module).%EvaluateSystemExpression(search)
if (resolved '= search) {
set data = $replace(data, search, resolved)
}
}
}
}
if data '= initialData {
set customParams(param) = data
set found = 1
}
}
}
}

Storage Default
{
<Data name="DefaultState">
Expand Down
177 changes: 177 additions & 0 deletions tests/integration_tests/Test/PM/Integration/Module.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
Class Test.PM.Integration.Module Extends Test.PM.Integration.Base
{

Parameter CommonPathPrefix As STRING = "varresolver";

Parameter ModuleName As STRING = "demo-module1";

Parameter ClsDefName As STRING = "Test.TrackPlaceHolders";

/// This test validates that the IPM engine can handle "chained" ${variables}
/// (placeholders that resolve to other placeholders).
/// 1. Generates a 'module.xml' from XData with deep variable dependencies.
/// 2. Executes IPM Shell 'load' to verify multi-pass expansion.
/// 3. Ensures no unresolved placeholders remain after the load process.
Method TestNestedPlaceholderVar()
{
do $$$LogMessage("Create '"_..#ClsDefName_"' class definition and method 'CaptureResolvedPlaceHolders' to capturing the placeholder variables")
set status = ..CreateClassdef()
do $$$AssertStatusOK(status,"Class definition created successfully")

do $$$LogMessage(" start Loading the "_..#ModuleName_" module")
set status = ..CreateModuleXml(.moduleDir)
do $$$AssertStatusOK(status,"Created the xml file on "_moduleDir)

set status = ##class(%IPM.Main).Shell("load "_moduleDir)
do $$$AssertStatusOK(status,"Loaded "_..#CommonPathPrefix_" module successfully from "_moduleDir)

do ..ValidateResolvedVarsValues()

set module = ##class(%IPM.Storage.Module).NameOpen(..#ModuleName)
do $$$AssertTrue($isobject(module), "Module "_..#ModuleName_" exists in IPM and version is "_ module.Version.ToString())

do $$$LogMessage("List all modules")
set status = ##class(%IPM.Main).Shell("list")
Comment thread
isc-dchui marked this conversation as resolved.
do $$$AssertStatusOK(status,"List all modules")

set status = ##class(%IPM.Main).Shell("uninstall "_..#ModuleName)
do $$$AssertStatusOK(status,"uninstalled module "_..#ModuleName_" successfully.")

set status = ##class(%File).Delete(##class(%File).NormalizeFilename("module.xml",moduleDir))
do $$$AssertStatusOK(status,"Deleted the module.xml file from "_moduleDir)

do $$$LogMessage("Deleting the "_..#ClsDefName_" generated test class")
set status = ##class(%Dictionary.ClassDefinition).%DeleteId(..#ClsDefName)
do $$$AssertStatusOK(status,"class "_..#ClsDefName_" is deleted successfully")

do $$$LogMessage("Deleting the ^IRIS.Temp.IPMVarTest global")
kill ^IRIS.Temp.IPMVarTest
}

Method ValidateResolvedVarsValues()
{
// testdatas
set customParamsOut("frankenstein") = $namespace
set customParamsOut("ipmtest")="TESTING MY STRING"
set customParamsOut("literalString1")=12121212
set customParamsOut("dataDefaultTest2")="/usr/irissys/csp//xdata"
set customParamsOut("dataDefaultTest3")="/usr/irissys/csp//xdata/xtest"
set customParamsOut("datadefaultcspdir")=##class(%IPM.Utils.Module).%EvaluateSystemExpression("${cspdir}")
set customParamsOut("datadefaultmgrdir")=##class(%IPM.Utils.Module).%EvaluateSystemExpression("${mgrdir}")
set customParamsOut("datapath1")="/usr/irissys/mgr/user/mtsdata/"
set customParamsOut("dataversion")="1.0.0"
set customParamsOut("ipmdir")="/usr/irissys/ipm/demo-module1/1.0.0/"
set customParamsOut("ipmtest")="TESTING MY STRING"
set customParamsOut("mTestVersion")="/usr/irissys/csp//xdata/xtest/1.0.0"

do $$$LogMessage("Verifying parameter values")
do $$$LogMessage(" ^IRIS.Temp.IPMVarTest set on Namespace: "_$namespace)
set param=""
for {
set param = $order(customParamsOut(param),1,parmaData)
quit:param=""
set tempVal = $get(^IRIS.Temp.IPMVarTest(param))
do $$$AssertEquals(tempVal, parmaData, "customParamsOut("""_param_""") value ' "_parmaData_"' is same as the value of ^IRIS.Temp.IPMVarTest("""_param_""") '"_tempVal_"'")
}
}

/// Dynamically creates a class definition at runtime to capture resolved reference values via the <invoke> tag within the manifest.
Method CreateClassdef() As %Status
{
set className = ..#ClsDefName
if ##class(%Dictionary.ClassDefinition).%ExistsId(className) {
do $$$LogMessage("Class "_className_ "aready exist. Do deleting the class.")
set status = $system.OBJ.Delete(className)
do $$$AssertStatusOK(status, "Deleted the class successfully.")
}
set cls = ##class(%Dictionary.ClassDefinition).%New()
set cls.Name = className

// create sample method
set method = ##class(%Dictionary.MethodDefinition).%New()
set method.Name = "CaptureResolvedPlaceHolders"
set method.ClassMethod = 1
set method.FormalSpec = "args..."
do method.Implementation.WriteLine($char(9)_"set fields = $LFS(""frankenstein,dataversion,literalString1,datadefaultcspdir,dataDefaultTest2,dataDefaultTest3,mTestVersion,ipmtest,datapath1,ipmdir,datadefaultmgrdir"")")
do method.Implementation.WriteLine($char(9)_"for i=1:1:$listLength(fields) {")
do method.Implementation.WriteLine($char(9)_" set ^IRIS.Temp.IPMVarTest($listget(fields,i))=$get(args(i))")
do method.Implementation.WriteLine($char(9)_"}")
do cls.Methods.Insert(method)
set sc = cls.%Save()
do $system.OBJ.Compile(className)
return sc
}

Method CreateModuleXml(Output pModuleDir) As %Status
{
#define NormalizeDirectory(%path) ##class(%File).NormalizeDirectory(%path)
#define UTRoot ^UnitTestRoot

set sc = $$$OK
set testRoot = $$$NormalizeDirectory($get($$$UTRoot))
set pModuleDir = $$$NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/"_..#CommonPathPrefix_"/")

if '##class(%File).DirectoryExists(pModuleDir) {
set sc = ##class(%File).CreateDirectoryChain(pModuleDir)
}
do $$$AssertStatusOK(sc,"Directory created "_pModuleDir)
set stream = ##class(%Dictionary.CompiledXData).%OpenId(..%ClassName(1)_"||TestModuleXML").Data
set fileStream = ##class(%Stream.FileBinary).%New()
set fileStream.Filename=##class(%File).NormalizeFilename("module.xml",pModuleDir)
set sc = fileStream.CopyFromAndSave(stream)
do $$$AssertStatusOK(1,"module.xml File created on "_pModuleDir)

return sc
}

/// Sample module file
XData TestModuleXML [ MimeType = application/xml ]
{
<?xml version="1.0" encoding="UTF-8"?>
<Export generator="Cache" version="25">
<Document name="demo-module1.ZPM">
<Module>
<Name>demo-module1</Name>
<Version>1.0.0</Version>
<Description>testing the name resolved</Description>
<Packaging>module</Packaging>
<Default Name="count" Value="7"/>
<Default Name="dataversion" Value="${version}" />
<Default Name="datadefaultmgrdir" Value="${mgrdir}" />
<Default Name="mTestPlaceHolder" Value="${dataDefaultTest2}/xtest/${version}/${mgrdir}/${mTestVersion}/${datapath1}" />
<Default Name="datadefaultcspdir" Value="${cspdir}" />
<Default Name="dataDefaultTest2" Value="${datadefaultcspdir}/xdata" />
<Default Name="dataDefaultTest3" Value="${dataDefaultTest2}/xtest" />
<Default Name="mTestVersion" Value="${dataDefaultTest2}/xtest/${version}" />
<Default Name="ipmtest" Value="TESTING MY STRING"/>
<Default Name="ipmdir" Value="/usr/irissys/mgr/user/mts"/>
<Default Name="datapath" Value="${libdir}data/" />
<Default Name="datapath1" Value="${ipmdir}data/" />
Comment thread
isc-dchui marked this conversation as resolved.
<Default Name="version" Value="1.0.0" />

<Default Name="start" Value="${" />
<Default Name="middle" Value="namespace" />
<Default Name="end" Value="}" />
<Default Name="frankenstein" Value="${start}${middle}${end}"/>
<SystemRequirements Version=">=2020.1" Interoperability="enabled"/>
<SourcesRoot>src</SourcesRoot>
<Invoke Class="Test.TrackPlaceHolders" Method="CaptureResolvedPlaceHolders">
<Arg>${frankenstein}</Arg>
<Arg>${dataversion}</Arg>
<Arg>12121212</Arg>
<Arg>${datadefaultcspdir}</Arg>
<Arg>${dataDefaultTest2}</Arg>
<Arg>${dataDefaultTest3}</Arg>
<Arg>${mTestVersion}</Arg>
<Arg>${ipmtest}</Arg>
<Arg>${datapath1}</Arg>
<Arg>${ipmdir}</Arg>
<Arg>${datadefaultmgrdir}</Arg>
</Invoke>
<AfterInstallMessage>Module installed successfully!</AfterInstallMessage>
</Module>
</Document>
</Export>
}

}
55 changes: 55 additions & 0 deletions tests/unit_tests/Test/PM/Unit/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,59 @@ Method TestFixUndefinedCLIGenCommand()
do $$$AssertStatusOK(sc, "AddWebApps method must now process web app list without error.")
}

Method TestResolveAllVariables()
{
//Setup Test Data
set customParams("count") = 7
set customParams("version") = "1.0.0"
set customParams("datadefaultcspdir") = "${cspdir}"
set customParams("datadefaultmgrdir") = "${mgrdir}"
set customParams("dataversion") = "${version}"
set customParams("dataDefaultTest2") = "${datadefaultcspdir}xdata"
set customParams("dataDefaultTest3") = "${dataDefaultTest2}xtest"
set customParams("datapath") = "${libdir}data/"
set customParams("ipmdir") = "/usr/irissys/mgr/user/mts"
set customParams("datapath1") = "${ipmdir}data/"
set customParams("mTestVersion") = "${dataDefaultTest2}/xtest/${version}"
set customParams("mTestPlaceHolder") = "${dataDefaultTest2}/xtest/${version}${mgrdir}${mTestVersion}${datapath1}"

// Frankenstein Variable Assembly
// These variables test the "Multi-Pass" capability of the resolver.
// It must first resolve 'start', 'middle', and 'end' to build a new
// valid placeholder string ("${namespace}"), and then resolve that
// string against the System Dictionary in a subsequent pass.
set customParams("start") = "${"
set customParams("middle") = "namespace"
set customParams("end") = "}"
set customParams("frankenstein") = "${start}${middle}${end}"

merge customParamsIn = customParams
// output
set customParamsOut("count")=7
set customParamsOut("dataDefaultTest2")="/usr/irissys/csp/xdata"
set customParamsOut("dataDefaultTest3")="/usr/irissys/csp/xdataxtest"
set customParamsOut("datadefaultcspdir")=##class(%IPM.Utils.Module).%EvaluateSystemExpression("${cspdir}")
set customParamsOut("datadefaultmgrdir")=##class(%IPM.Utils.Module).%EvaluateSystemExpression("${mgrdir}")
set customParamsOut("datapath")=##class(%IPM.Utils.Module).%EvaluateSystemExpression("${libdir}")_"data/"
set customParamsOut("datapath1")="/usr/irissys/mgr/user/mtsdata/"
set customParamsOut("dataversion")="1.0.0"
set customParamsOut("ipmdir")="/usr/irissys/mgr/user/mts"
set customParamsOut("mTestPlaceHolder")="/usr/irissys/csp/xdata/xtest/1.0.0/usr/irissys/mgr//usr/irissys/csp/xdata/xtest/1.0.0/usr/irissys/mgr/user/mtsdata/"
set customParamsOut("mTestVersion")="/usr/irissys/csp/xdata/xtest/1.0.0"
set customParamsOut("version")="1.0.0"
set customParamsOut("start") = "${"
set customParamsOut("middle") = "namespace"
set customParamsOut("end") = "}"
set customParamsOut("frankenstein") = "USER"
do ##class(%IPM.Storage.ModuleSetting.Default).ResolvePlaceholders(.customParams)

do $$$LogMessage("Validate all placholder variables")
set variable=""
for {
set variable = $order(customParams(variable))
quit:variable=""
do $$$AssertEquals(customParams(variable), customParamsOut(variable), variable_" is resolved correctly and the placeholder "_customParamsIn(variable)_" value is "_customParamsOut(variable))
}
}

}
Loading