diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f72ed274..5f4f53bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - #992: Implement automatic history purge logic - #973: Enables CORS and JWT configuration for WebApplications in module.xml - +- #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 - #1052: In a namespace with mapped IPM, the `info` command works again and the intro message displays the IPM version and where its mapped from diff --git a/src/cls/IPM/Storage/Module.cls b/src/cls/IPM/Storage/Module.cls index c5b402a58..d228bce8e 100644 --- a/src/cls/IPM/Storage/Module.cls +++ b/src/cls/IPM/Storage/Module.cls @@ -1780,6 +1780,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) diff --git a/src/cls/IPM/Storage/ModuleSetting/Default.cls b/src/cls/IPM/Storage/ModuleSetting/Default.cls index 543500c34..99e3865c7 100644 --- a/src/cls/IPM/Storage/ModuleSetting/Default.cls +++ b/src/cls/IPM/Storage/ModuleSetting/Default.cls @@ -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) +{ + set found = 1 + // maxLevels is a safety guard to prevents infinite loops caused by circular references. + set maxLevels = 20 + + 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 + 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 { diff --git a/tests/integration_tests/Test/PM/Integration/Module.cls b/tests/integration_tests/Test/PM/Integration/Module.cls new file mode 100644 index 000000000..b47622d78 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/Module.cls @@ -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") + 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 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 ] +{ + + + + + demo-module1 + 1.0.0 + testing the name resolved + module + + + + + + + + + + + + + + + + + + + + src + + ${frankenstein} + ${dataversion} + 12121212 + ${datadefaultcspdir} + ${dataDefaultTest2} + ${dataDefaultTest3} + ${mTestVersion} + ${ipmtest} + ${datapath1} + ${ipmdir} + ${datadefaultmgrdir} + + Module installed successfully! + + + +} + +} diff --git a/tests/unit_tests/Test/PM/Unit/Module.cls b/tests/unit_tests/Test/PM/Unit/Module.cls index bffe7cd12..1e0e7ecd5 100644 --- a/tests/unit_tests/Test/PM/Unit/Module.cls +++ b/tests/unit_tests/Test/PM/Unit/Module.cls @@ -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)) + } +} + }