diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36eafe6d..12c55d2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.8.0] - Unreleased
### Added
+- Production Decomposition mode allows controlling interoperability productions as individual files for each host (#469)
+- Added saving settings as system default for new namespaces (#535)
- Added filtering through branch names in UI (#615)
## [2.7.1] - 2024-11-13
diff --git a/cls/SourceControl/Git/API.cls b/cls/SourceControl/Git/API.cls
index c85543ac..e77ec05f 100644
--- a/cls/SourceControl/Git/API.cls
+++ b/cls/SourceControl/Git/API.cls
@@ -75,4 +75,11 @@ ClassMethod MapEverywhere()
Quit ##class(SourceControl.Git.Installer).MapEverywhere()
}
+/// Run to baseline all interoperability productions in the namespace to source control.
+/// This should be done after changing the value of the "decompose productions" setting.
+ClassMethod BaselineProductions()
+{
+ do ##class(SourceControl.Git.Util.Production).BaselineProductions()
+}
+
}
diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls
index 1b5d6ce4..d9f4287a 100644
--- a/cls/SourceControl/Git/Extension.cls
+++ b/cls/SourceControl/Git/Extension.cls
@@ -327,22 +327,44 @@ Method OnBeforeTimestamp(InternalName As %String)
Method OnAfterSave(InternalName As %String, Object As %RegisteredObject = {$$$NULLOREF}) As %Status
{
set sc = $$$OK
+ quit:$get(%gscSkipSaveHooks) sc
try {
set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(.InternalName,.fromWebApp,.fullExternalName)
set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(InternalName)
- if ##class(SourceControl.Git.Utils).IsNamespaceInGit() && ..IsInSourceControl(InternalName) {
- if fromWebApp {
- if fullExternalName = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) {
- // Reimport item into database
- $$$ThrowOnError(##class(SourceControl.Git.Utils).ImportItem(InternalName,,1,1))
+ if ##class(SourceControl.Git.Utils).IsNamespaceInGit() {
+ // If this is a production class and production decomposition is enabled, call recursively on all modified production items.
+ if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(InternalName) {
+ do ##class(SourceControl.Git.Production).GetModifiedItemsAfterSave(InternalName, .productionItems)
+ set key = $order(productionItems(""))
+ while (key '= "") {
+ if productionItems(key) = "D" {
+ set itemFilename = ..FullExternalName(key)
+ if ##class(SourceControl.Git.Utils).IsInSourceControl(key) && ##class(%File).Exists(itemFilename) {
+ $$$ThrowOnError(##class(SourceControl.Git.Change).AddDeletedToUncommitted(itemFilename, key))
+ $$$ThrowOnError(##class(SourceControl.Git.Utils).DeleteExternalFile(key))
+ }
+ } elseif '..IsInSourceControl(key) {
+ $$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(key))
+ } else {
+ $$$ThrowOnError(..OnAfterSave(key))
+ }
+ set key = $order(productionItems(key))
}
- } else {
- set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName)
- $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName))
- set forceExport = (InternalName'= "") && ($data(..Modified(InternalName)))
- $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName,,forceExport))
- if '##class(SourceControl.Git.Change).IsUncommitted(filename) {
- $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0))
+ }
+ if ..IsInSourceControl(InternalName) {
+ if fromWebApp {
+ if fullExternalName = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) {
+ // Reimport item into database
+ $$$ThrowOnError(##class(SourceControl.Git.Utils).ImportItem(InternalName,,1,1))
+ }
+ } else {
+ set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName)
+ $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName))
+ set forceExport = (InternalName'= "") && ($data(..Modified(InternalName)))
+ $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName,,forceExport))
+ if '##class(SourceControl.Git.Change).IsUncommitted(filename) {
+ $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0))
+ }
}
}
}
@@ -400,9 +422,41 @@ Method ExternalName(InternalName As %String) As %String
quit ##class(SourceControl.Git.Utils).ExternalName(InternalName)
}
+ClassMethod FullExternalName(InternalName As %String) As %String
+{
+ quit ##class(SourceControl.Git.Utils).FullExternalName(InternalName)
+}
+
Method IsReadOnly(InternalName As %String) As %Boolean
{
- quit ##class(SourceControl.Git.Utils).Locked()
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ quit (##class(SourceControl.Git.Utils).Locked()
+ && '$get(^IRIS.Temp("sscProd",$job,"bypassLock")))
+ || (##class(SourceControl.Git.Utils).ItemIsProductionToDecompose($get(InternalName))
+ && 'settings.decomposeProdAllowIDE
+ && '##class(SourceControl.Git.Production).IsEnsPortal())
+}
+
+/// Called before the item is saved to the database it is passed
+/// a reference to the current temporary storage of this item so that it
+/// can be modified before the save completes. If you quit with an error
+/// value then it will abort the save.
+Method OnBeforeSave(InternalName As %String, Location As %String = "", Object As %RegisteredObject = {$$$NULLOREF}) As %Status
+{
+ set st = $$$OK
+ if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(InternalName) {
+ do ##class(SourceControl.Git.Production).GetModifiedItemsBeforeSave(InternalName,,.productionItems)
+ set key = $order(productionItems(""))
+ while (key '= "") {
+ // if any modified items in this production class are checked out by a different user, fail the check.
+ set st = ..GetStatus(key, .IsInSourceControl, .Editable, .IsCheckedOut, .UserCheckedOut)
+ quit:$$$ISERR(st)
+ if 'Editable set st = $$$ERROR($$$GeneralError,"Item is checked out by another user: "_UserCheckedOut)
+ quit:$$$ISERR(st)
+ set key = $order(productionItems(key))
+ }
+ }
+ return st
}
/// Check the status of the given item
@@ -411,7 +465,7 @@ Method IsReadOnly(InternalName As %String) As %Boolean
Method GetStatus(ByRef InternalName As %String, ByRef IsInSourceControl As %Boolean, ByRef Editable As %Boolean, ByRef IsCheckedOut As %Boolean, ByRef UserCheckedOut As %String) As %Status
{
set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(.InternalName)
- set Editable='..IsReadOnly(),IsCheckedOut=1,UserCheckedOut=""
+ set Editable='..IsReadOnly($get(InternalName)),IsCheckedOut=1,UserCheckedOut=""
set filename=##class(SourceControl.Git.Utils).FullExternalName(.InternalName)
set IsInSourceControl=(filename'=""&&($$$FileExists(filename)))
if filename="" quit $$$OK
diff --git a/cls/SourceControl/Git/File.cls b/cls/SourceControl/Git/File.cls
index 951b8446..9ace3374 100644
--- a/cls/SourceControl/Git/File.cls
+++ b/cls/SourceControl/Git/File.cls
@@ -35,8 +35,23 @@ ClassMethod ExternalNameToInternalName(ExternalName As %String) As %String
}
new %SourceControl //don't trigger source hooks with this test load to get the Name
set sc=$system.OBJ.Load(ExternalName,"-d",,.outName,1)
- if (($data(outName)=1) || ($data(outName) = 11 && ($order(outName(""),-1) = $order(outName(""))))) && ($zconvert(##class(SourceControl.Git.Utils).Type(outName),"U") '= "CSP") {
+ set itemIsPTD = 0
+ if $data(outName) = 11 {
+ set key = $order(outName(""))
+ while (key '= "") {
+ if ($zconvert($piece(outName,".",*),"U") = "PTD") {
+ set itemIsPTD = 1
+ quit
+ }
+ set key = $order(outName(key))
+ }
+ }
+ if itemIsPTD && ##class(%Library.EnsembleMgr).IsEnsembleNamespace() {
+ do ##class(SourceControl.Git.Production).ParseExternalName($replace(ExternalName,"\","/"),.internalName)
+ } elseif (($data(outName)=1) || ($data(outName) = 11 && ($order(outName(""),-1) = $order(outName(""))))) && ($zconvert(##class(SourceControl.Git.Utils).Type(outName),"U") '= "CSP") {
set internalName = outName
+ }
+ if (internalName '= "") {
set inst.InternalName = internalName
$$$ThrowOnError(inst.%Save())
}
@@ -68,4 +83,4 @@ Storage Default
%Storage.Persistent
}
-}
\ No newline at end of file
+}
diff --git a/cls/SourceControl/Git/PackageManagerContext.cls b/cls/SourceControl/Git/PackageManagerContext.cls
index c75ea4b0..103f4722 100644
--- a/cls/SourceControl/Git/PackageManagerContext.cls
+++ b/cls/SourceControl/Git/PackageManagerContext.cls
@@ -18,6 +18,10 @@ Method InternalNameSet(InternalName As %String = "") As %Status
set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName)
if (InternalName '= i%InternalName) {
set i%InternalName = InternalName
+ if (InternalName = ##class(SourceControl.Git.Settings.Document).#INTERNALNAME) {
+ // git source control settings document is never in an IPM context
+ quit $$$OK
+ }
if $$$comClassDefined("%IPM.ExtensionBase.Utils") {
set ..Package = ##class(%IPM.ExtensionBase.Utils).FindHomeModule(InternalName,,.resourceReference)
} elseif $$$comClassDefined("%ZPM.PackageManager.Developer.Extension.Utils") {
@@ -50,4 +54,4 @@ Method Dump()
write !?4,"Git-enabled? ",$select(..IsInGitEnabledPackage:"Yes",1:"No"),!
}
-}
\ No newline at end of file
+}
diff --git a/cls/SourceControl/Git/Production.cls b/cls/SourceControl/Git/Production.cls
new file mode 100644
index 00000000..1ffbe553
--- /dev/null
+++ b/cls/SourceControl/Git/Production.cls
@@ -0,0 +1,531 @@
+/// This is a replica of %Studio.SourceControl.Production for backwards compatibility with older IRIS versions.
+Class SourceControl.Git.Production Extends %RegisteredObject
+{
+
+/// Exports settings for a given Production and each Config Item from
+/// the ProductionDefinition as separate XMLs. These are exported to
+/// the appropriate file based on nameMethod of the source control class
+ClassMethod ExportProductionDefinitionShards(productionClass As %String, nameMethod As %String, Output internalNames) As %Status
+{
+ Set sc = ..ExportProductionSettings(productionClass, nameMethod, .internalName)
+ If $$$ISERR(sc) {
+ Return sc
+ }
+ Set internalNames(internalName) = 1
+
+ // next, export each item to a separate file
+ Set rs = ..ExecDirectNoPriv(
+ "select Name, ClassName from Ens_Config.Item where Production = ?"
+ , productionClass
+ )
+ Throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ While rs.%Next() {
+ Set ptdName = ""
+ Set item = ##class(Ens.Config.Production).OpenItemByConfigName(productionClass _ "||" _ rs.Name _ "|" _ rs.ClassName)
+ If $isobject(item) {
+ Set sc = ..ExportConfigItemSettings(productionClass, item, nameMethod, .internalName)
+ If $$$ISERR(sc) {
+ Return sc
+ }
+ Set internalNames(internalName) = 1
+ }
+ }
+ Return $$$OK
+}
+
+/// Removes settings for a given Production and each Config Item from
+/// source control. This marks each of them for delete and is triggered
+/// by deleting a decomposed Production.
+ClassMethod DeleteProductionDefinitionShards(productionClass As %String, deleteMethod As %String, nameMethod As %String) As %Status
+{
+ set sc = $$$OK
+ if '$isobject($get(%SourceControl)) {
+ new %SourceControl
+ $$$ThrowOnError(##class(%Studio.SourceControl.Interface).SourceControlCreate())
+ }
+ set settingsPTD = ..CreateInternalName(productionClass,,,1)
+ set sourceControlClass = ##class(%Studio.SourceControl.Interface).SourceControlClassGet()
+ set settingsPTDFile = $classmethod(sourceControlClass, nameMethod, settingsPTD)
+ // if the Production settings PTD exists, delete all PTDs for this Production
+ if ##class(%File).Exists(settingsPTDFile) {
+ set ptdDir = ##class(%File).GetDirectory(settingsPTDFile)
+ set statement = ##class(%SQL.Statement).%New()
+ try {
+ // execute without priv checking if possible on this IRIS version
+ set sc = statement.%PrepareClassQuery("%File","FileSet",0)
+ } catch err {
+ set sc = statement.%PrepareClassQuery("%File","FileSet")
+ }
+ quit:$$$ISERR(sc)
+ set rs = statement.%Execute(ptdDir, "*.xml")
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ while rs.%Next(.sc) {
+ quit:$$$ISERR(sc)
+ set ptdFilename = rs.Data("Name")
+ set sc = ##class(%Studio.SourceControl.Production).ParseExternalName(ptdFilename, .ptdInternalName)
+ quit:$$$ISERR(sc)
+ // TODO: Consider reverting delete if any ptd is not editable by current user
+ set sc = $method(%SourceControl, deleteMethod, ptdInternalName)
+ quit:$$$ISERR(sc)
+ }
+ }
+ return sc
+}
+
+/// Exports a Studio project including both the provided PTD and export notes for the PTD
+ClassMethod ExportProjectForPTD(productionClass, ptdName, exportPath) As %Status
+{
+ set st = $$$OK
+ try {
+ set project = ##class(%Studio.Project).%New()
+ set project.Name = $replace($replace(ptdName,".","_"),":","-")
+ kill projContentsList
+ set projContentsList(ptdName _ ".PTD") = ""
+ $$$ThrowOnError(##class(Ens.Deployment.Utils).CreateExportNotesPTD(project.Name,productionClass,,.projContentsList,0,.exportNotesPTDName))
+ // strip items from export notes that break our diff
+ set st = ##class(Ens.Util.ProjectTextDocument).GetStream(.notesStream, exportNotesPTDName)
+ quit:$$$ISERR(st)
+ set newNotesStream = ##class(%Stream.GlobalCharacter).%New()
+ while 'notesStream.AtEnd {
+ set line = notesStream.ReadLine()
+ if $match(line, "^<(Machine|Instance|Namespace|Username)>.*") {
+ // remove these
+ } elseif $match(line, "^.*") {
+ // dummy timestamp for source control hooks to work properly
+ set st = newNotesStream.WriteLine("1841-01-01 00:00:00.000")
+ quit:$$$ISERR(st)
+ } else {
+ set st = newNotesStream.WriteLine(line)
+ quit:$$$ISERR(st)
+ }
+ }
+ do:##class(%RoutineMgr).Exists(exportNotesPTDName_".PTD") ##class(%RoutineMgr).Delete(exportNotesPTDName_".PTD")
+ set st = ##class(Ens.Util.ProjectTextDocument).Create(newNotesStream, exportNotesPTDName, "Export Notes for export "_project.Name)
+ quit:$$$ISERR(st)
+ // Internal/External naming logic relies on Export Notes being added to project first. If this is changed check for dependencies
+ do project.AddItem(exportNotesPTDName_".PTD")
+ do project.AddItem(ptdName_".PTD")
+ $$$ThrowOnError(project.%Save())
+ set projContentsList(exportNotesPTDName_".PTD") = ""
+ set projContentsList(project.Name_".PRJ") = ""
+ $$$ThrowOnError($System.OBJ.Export(.projContentsList, exportPath, "/diffexport=1"))
+ // remove the LastModified timestamp from the exported file
+ set fileStream = ##class(%Stream.FileCharacter).%OpenId(exportPath,,.st)
+ $$$ThrowOnError(st)
+ set fileCopy = ##class(%Stream.TmpCharacter).%New()
+ set timestampFound = 0
+ while 'fileStream.AtEnd {
+ set line = fileStream.ReadLine()
+ set regex = ##class(%Regex.Matcher).%New("\")
+ if 'timestampFound && regex.Match(line) {
+ set timestampFound = 1
+ set timestamp = regex.Group(1)
+ set line = $replace(line, timestamp, "1841-01-01 00:00:00.0000000")
+ }
+ do fileCopy.WriteLine(line)
+ }
+ $$$ThrowOnError(fileStream.CopyFromAndSave(fileCopy))
+ } catch err {
+ set st = err.AsStatus()
+ }
+ if $IsObject(project) {
+ set st = $$$ADDSC(st,##class(%Studio.Project).%DeleteId(project.Name))
+ }
+ return st
+}
+
+/// Creates and exports a PTD item for a given internal name, either a single config item
+/// or the production settings.
+ClassMethod ExportPTD(internalName As %String, nameMethod) As %Status
+{
+ Set name = $Piece(internalName,".",1,$Length(internalName,".")-1)
+ Set $ListBuild(productionName, itemName) = $ListFromString(name, "||")
+ Set $ListBuild(itemName, itemClassName) = $ListFromString(itemName, "|")
+ Set sc = $$$OK
+ If $Piece($Piece(name,"||",2),"|",2) = "" {
+ Set sc = ..ExportProductionSettings(productionName, nameMethod)
+ } Else {
+ Set configItemName = productionName_"||"_$Piece(itemName, "Settings-", 2)_"|"_itemClassName
+ Set item = ##class(Ens.Config.Production).OpenItemByConfigName(configItemName)
+ If $IsObject(item) {
+ Set sc = ..ExportConfigItemSettings(productionName, item, nameMethod)
+ }
+ }
+ Return sc
+}
+
+/// Imports a PTD into a produciton given an external name and produciton name
+ClassMethod ImportPTD(externalName As %String, productionName As %String) As %Status
+{
+ try {
+ set ^IRIS.Temp("sscProd",$job,"bypassLock") = 1
+ set rollbackFile = ##class(%File).TempFilename()
+ set sc = ##class(Ens.Deployment.Deploy).DeployCode(externalName,productionName,0,rollbackFile)
+ do ##class(%File).Delete(rollbackFile)
+ kill ^IRIS.Temp("sscProd",$job,"bypassLock")
+ } catch err {
+ kill ^IRIS.Temp("sscProd",$job,"bypassLock")
+ set sc = err.AsStatus()
+ }
+ return sc
+}
+
+/// Imports all PTDs within a given directory. Also recursively imports from all subdirectories
+ClassMethod ImportPTDsDir(directory As %String, isDecompMethod As %String = "") As %Status
+{
+ set sc = $$$OK
+ set rs = ##class(%ResultSet).%New("%File:FileSet")
+ $$$ThrowOnError(rs.Execute(directory, "*.xml", "", 1))
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE, rs.%Message)
+ while rs.Next() {
+ set path = rs.Data("Name")
+ set type = rs.Data("Type")
+ if type = "D" {
+ set sc = ..ImportPTDsDir(path)
+ } else {
+ $$$ThrowOnError(..ParseExternalName(path, .internalName, .prodName))
+ set srcCtrlCls = ##class(%Studio.SourceControl.Interface).SourceControlClassGet()
+ set isDecomp = $select(isDecompMethod="":1, 1:$classMethod(srcCtrlCls, isDecompMethod, internalName))
+ if isDecomp {
+ set filename = ##class(%File).GetFilename(path)
+ if ($extract(filename) = "P") && '$$$comClassDefined(prodName) {
+ $$$ThrowOnError(..CreateProduction(prodName))
+ }
+ set sc = ..ImportPTD(path, prodName)
+ }
+ }
+ }
+ return sc
+}
+
+/// Export a single Production Config Item. For a given Ens.Config.Item, the
+/// exports the PTD for this item to the file system under the directory specified
+ClassMethod ExportConfigItemSettings(productionClass As %String, item As %RegisteredObject, nameMethod As %String, Output internalName As %String) As %Status
+{
+ set internalName = ..CreateInternalName(productionClass, item.Name, item.ClassName, 0)
+ Set externalName = $ClassMethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, internalName)
+ Set filename = ##class(%File).NormalizeFilename(externalName)
+ set st = ##class(Ens.Deployment.Utils).CreatePTDFromItem(.item, .ptdName)
+ $$$QuitOnError(st)
+ set st = ..ExportProjectForPTD(productionClass, ptdName, filename)
+ $$$QuitOnError(st)
+ Return st
+}
+
+/// Exports the Production settings from ProductionDefinition given the Production
+/// class name
+ClassMethod ExportProductionSettings(productionClass As %String, nameMethod As %String, Output internalName As %String) As %Status
+{
+ set internalName = ..CreateInternalName(productionClass,,,1)
+ Set class = ##class(%Dictionary.CompiledClass).%OpenId(productionClass)
+ Set sc = ##class(Ens.Deployment.Utils).CreatePTDFromProduction(class, .ptdName)
+ If $$$ISERR(sc) {
+ Return sc
+ }
+ Set externalName = $ClassMethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, internalName)
+ Set filename = ##class(%File).NormalizeFilename(externalName)
+ set sc = ..ExportProjectForPTD(productionClass, ptdName, filename)
+ Return sc
+}
+
+ClassMethod GetModifiedItemsBeforeSave(internalName, Location, Output modifiedItems)
+{
+ kill modifiedItems
+ set productionName = $piece(internalName,".",1,*-1)
+ set productionConfig = ##class(Ens.Config.Production).%OpenId(productionName)
+ if ..IsEnsPortal() {
+ // If editing from SMP, get the modified items by looking at %IsModified on the items in the production in memory.
+ // No way to know if an item has been added or deleted, so ignore it.
+ if $isobject(productionConfig) {
+ set modifiedItem = $$$NULLOREF
+ for i=1:1:productionConfig.Items.Count() {
+ set item = productionConfig.Items.GetAt(i)
+ if item.%IsModified() {
+ set modifiedItem = item
+ quit
+ }
+ for j=1:1:item.Settings.Count() {
+ set setting = item.Settings.GetAt(j)
+ if $isobject(setting) && setting.%IsModified() {
+ set modifiedItem = item
+ quit
+ }
+ }
+ }
+ set modifiedInternalName = ""
+ if $isobject(modifiedItem) {
+ set modifiedInternalName = ..CreateInternalName(productionName, modifiedItem.Name, modifiedItem.ClassName, 0)
+ } else {
+ // cannot check %IsModified on production config settings because they are not actually modified at this point.
+ // workaround: just assume any change not to a specific item is to the production settings
+ set modifiedInternalName = ..CreateInternalName(productionName,,,1)
+ }
+ }
+ if ($get(modifiedInternalName) '= "") {
+ set modifiedItems(modifiedInternalName) = "M"
+ }
+ } else {
+ // FUTURE: get the actually modified items by comparing the XDATA in Location with the XDATA in the compiled class
+ // If making changes from Studio, list every item in the production.
+ if $isobject(productionConfig) {
+ set modifiedItems(..CreateInternalName(productionName,,,1)) = "M"
+ for i=1:1:productionConfig.Items.Count() {
+ set item = productionConfig.Items.GetAt(i)
+ set modifiedItems(..CreateInternalName(productionName, item.Name, item.ClassName, 0)) = "M"
+ }
+ }
+ }
+ // populate data for use in OnAfterSave
+ kill ^IRIS.Temp("sscProd",$job,"modifiedItems")
+ merge ^IRIS.Temp("sscProd",$job,"modifiedItems") = modifiedItems
+ // FUTURE: use a percent variable or PPG instead
+ kill ^IRIS.Temp("sscProd",$job,"items")
+ set rs = ..ExecDirectNoPriv(
+ "select Name, ClassName from Ens_Config.Item where Production = ?"
+ , productionName)
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ while rs.%Next() {
+ set ^IRIS.Temp("sscProd",$job,"items",$listbuild(rs.Name, rs.ClassName)) = 1
+ }
+}
+
+ClassMethod GetModifiedItemsAfterSave(internalName, Output modifiedItems)
+{
+ kill modifiedItems
+ set productionName = $piece(internalName,".",1,*-1)
+ if ..IsEnsPortal() {
+ // If adding/deleting from SMP, get the modified items by comparing items in temp global with items now
+ set rs = ..ExecDirectNoPriv(
+ "select Name, ClassName from Ens_Config.Item where Production = ?"
+ , productionName)
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ while rs.%Next() {
+ if '$get(^IRIS.Temp("sscProd",$job,"items", $listbuild(rs.Name, rs.ClassName))) {
+ set itemInternalName = ..CreateInternalName(productionName, rs.Name, rs.ClassName, 0)
+ set modifiedItems(itemInternalName) = "A"
+ }
+ kill ^IRIS.Temp("sscProd",$job,"items", $listbuild(rs.Name, rs.ClassName))
+ }
+ set key = $order(^IRIS.Temp("sscProd",$job,"items",""))
+ while (key '= "") {
+ set itemInternalName = ..CreateInternalName(productionName, $listget(key,1), $listget(key,2), 0)
+ set modifiedItems(itemInternalName) = "D"
+ set key = $order(^IRIS.Temp("sscProd",$job,"items",key))
+ }
+ // If editing from SMP, get the modified items from a cache stored in OnBeforeSave.
+ // Only do this if there are no added/deleted items, because otherwise production settings will be incorrectly included.
+ if '$data(modifiedItems) {
+ merge modifiedItems = ^IRIS.Temp("sscProd",$job,"modifiedItems")
+ }
+ } else {
+ // If editing in the IDE, list every item in the production.
+ // FUTURE: get the actually modified items using the temp global set in OnBeforeSave
+ set productionConfig = ##class(Ens.Config.Production).%OpenId(productionName)
+ if $isobject(productionConfig) {
+ set modifiedItems(..CreateInternalName(productionName,,,1)) = "M"
+ for i=1:1:productionConfig.Items.Count() {
+ set item = productionConfig.Items.GetAt(i)
+ set modifiedItems(..CreateInternalName(productionName, item.Name, item.ClassName, 0)) = "M"
+ }
+ }
+ }
+}
+
+/// Check if current CSP session is EnsPortal page
+ClassMethod IsEnsPortal() As %Boolean
+{
+ Return $Data(%request) && '($IsObject(%request) &&
+ ((%request.UserAgent [ "Code") || (%request.UserAgent [ "node-fetch")))
+}
+
+/// Perform check if Production Decomposition logic should be used for given item
+ClassMethod IsProductionClass(className As %String, nameMethod As %String) As %Boolean
+{
+ if (className '= "") && $$$comClassDefined(className) {
+ return $classmethod(className, "%Extends", "Ens.Production")
+ } else {
+ // check if there exists a Production settings PTD export for ths Production
+ set settingsPTD = ..CreateInternalName(className,,,1)
+ set settingsPTDFilename = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, settingsPTD)
+ if ##class(%File).Exists(settingsPTDFilename) {
+ return 1
+ }
+ // check if there is a class export for this Production, load it for class definition
+ set filename = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, className_".CLS")
+ if ##class(%File).Exists(filename) && (##class(%File).GetFileSize(filename) '= 0) {
+ $$$ThrowOnError($System.OBJ.Load(filename))
+ }
+ // if Production exists as a class definition on the server, check if extending Ens.Production
+ set classDef = ##class(%Dictionary.ClassDefinition).%OpenId(className)
+ if $isobject(classDef) {
+ for i=1:1:classDef.XDatas.Count() {
+ set xdata = classDef.XDatas.GetAt(i)
+ if xdata.Name = "ProductionDefinition" return 1
+ }
+ }
+ }
+ return 0
+}
+
+/// Given a file name for a PTD item, returns a suggested internal name. This method assumes that the file exists on disk.
+ClassMethod ParseExternalName(externalName, Output internalName = "", Output productionName = "") As %Status
+{
+ set sc = $$$OK
+ set extNameNormalized = $replace(externalName, "\", "/")
+ set file = $piece(extNameNormalized, "/", *)
+ if $extract(file,1,9) = "ProdStgs-" {
+ set productionName = $replace($extract(file,10,*-4), "_", ".")
+ set internalName = ..CreateInternalName(productionName,,,1)
+ } else {
+ if ##class(%File).Exists(externalName) {
+ // Special case for Config Item Settings PTD, requires checking PTD CDATA for Item and Class name
+ set deployDoc = ##class(EnsLib.EDI.XML.Document).%New(externalName)
+ set exportNotesPTDText = $ZCVT(deployDoc.GetValueAt("/Export/Document[1]/1"),"I","XML")
+ set exportNotesPTD = ##class(EnsLib.EDI.XML.Document).%New(exportNotesPTDText)
+ set productionName = exportNotesPTD.GetValueAt("/Deployment/Creation/SourceProduction")
+ set settingsPTDText = $zconvert(deployDoc.GetValueAt("/Export/Document[2]/1"),"I","XML")
+ set settingsPTD = ##class(EnsLib.EDI.XML.Document).%New(settingsPTDText)
+ set itemClass = settingsPTD.GetValueAt("/Item/@ClassName")
+ set itemName = settingsPTD.GetValueAt("/Item/@Name")
+ set internalName = ..CreateInternalName(productionName, itemName, itemClass, 0)
+ } else {
+ set sc = $$$ERROR($$$GeneralError, "Item settings PTD file " _ externalName _ " does not exist. Cannot parse external name.")
+ }
+ }
+ return sc
+}
+
+/// Given an internal name for a PTD item, returns a suggested filename for export, as well as:
+/// - itemName: name of the configuration item
+/// - productionName: name of the associated production
+/// - isProdSettings: if true, this item is a production settings; if false, this item is a configuration item settings
+ClassMethod ParseInternalName(internalName, noFolders As %Boolean = 0, Output fileName, Output itemName, Output itemClassName, Output productionName, Output isProdSettings As %Boolean)
+{
+ set name = $piece(internalName,".",1,*-1)
+ if 'noFolders {
+ set name = $replace(name,"||","/")
+ set $ListBuild(productionName, name) = $ListFromString(name, "/")
+ }
+ // Abbreviate "ProductionSettings" to "ProdStgs", "Settings" to "Stgs".
+ Set prefix = $Case($Extract(name), "P":"ProdStgs-", "S":"Stgs-", :"")
+ set isProdSettings = ($Extract(name) = "P")
+ Set name = $Piece(name,"-",2,*)
+ set $ListBuild(itemName, itemClassName) = $ListFromString(name, "|")
+ set name = prefix_$select(
+ $get(itemClassName) '= "": itemName_$zhex($zcrc(itemClassName,6)),
+ 1: name
+ )
+ if 'noFolders {
+ set name = productionName _ "/" _ name
+ }
+ set fileName = $translate($replace(name, ".", "_") _ ".xml", "\", "/")
+}
+
+/// Calculates the internal name for a decomposed production item
+ClassMethod CreateInternalName(productionName = "", itemName = "", itemClassName = "", isProductionSettings As %Boolean = 0) As %String
+{
+ return $select(
+ isProductionSettings: productionName_"||ProductionSettings-"_productionName_".PTD",
+ 1: productionName _ "||Settings-" _ itemName _ "|" _ itemClassName _ ".PTD"
+ )
+}
+
+/// Given an external name for a PTD item, removes that item from the production.
+ClassMethod RemoveItemByExternalName(externalName, nameMethod) As %Status
+{
+ set sc = $$$OK
+ set productionName = $replace($piece($replace(externalName,"\","/"),"/",*-1),"_",".")
+ set production = ##class(Ens.Config.Production).%OpenId(productionName,,.sc)
+ $$$QuitOnError(sc)
+ set itemToRemove = $$$NULLOREF
+ for i=1:1:production.Items.Count() {
+ set configItem = production.Items.GetAt(i)
+ set itemInternalName = ..CreateInternalName(productionName, configItem.Name, configItem.ClassName)
+ set itemExternalName = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, itemInternalName)
+ if itemExternalName = externalName {
+ set itemToRemove = configItem
+ quit
+ }
+ }
+ do production.RemoveItem(itemToRemove)
+ return production.%Save()
+}
+
+/// Given an internal name for a PTD item, removes that item from the production.
+ClassMethod RemoveItem(internalName, noFolders As %Boolean = 0) As %Status
+{
+ set sc = $$$OK
+ try {
+ if '##class(%Library.EnsembleMgr).IsEnsembleNamespace() {
+ quit
+ }
+ do ..ParseInternalName(internalName, noFolders, , .itemName, .itemClassName, .productionName, .isProdSettings)
+ if 'isProdSettings {
+ set production = ##class(Ens.Config.Production).%OpenId(productionName,,.sc)
+ quit:$$$ISERR(sc)
+ set configItem = ##class(Ens.Config.Production).OpenItemByConfigName(productionName_"||"_itemName_"|"_itemClassName,.sc)
+ quit:$$$ISERR(sc)
+ do production.RemoveItem(configItem)
+ set sc = production.%Save()
+ quit:$$$ISERR(sc)
+ }
+ } catch err {
+ set sc = err.AsStatus()
+ }
+ return sc
+}
+
+/// Given internal name for a Production Settings PTD, creates the corresponding Production
+/// Class if it does not already exist in this namespace
+ClassMethod CreateProduction(productionName As %String, superClasses = "") As %Status
+{
+ set classDef = ##class(%Dictionary.ClassDefinition).%New(productionName)
+ if superClasses '= "" {
+ set classDef.Super = superClasses
+ } else {
+ set classDef.Super = "Ens.Production"
+ }
+ set productionXData = ##class(%Dictionary.XDataDefinition).%New()
+ set productionXData.Name = "ProductionDefinition"
+ set sc = productionXData.Data.WriteLine("")
+ if $$$ISERR(sc) return sc
+ set sc = classDef.XDatas.Insert(productionXData)
+ if $$$ISERR(sc) return sc
+ set sc = classDef.%Save()
+ if $$$ISERR(sc) return sc
+ set sc = $System.OBJ.Compile(productionName)
+ return sc
+}
+
+/// Given an internal name of a Production and an IRIS user, populate an array with the filenames
+/// for each of their current uncommitted changes associated with the given Production
+ClassMethod GetUserProductionChanges(productionName As %String, ByRef items)
+{
+ set sql = "SELECT InternalName, Action FROM %Studio_SourceControl.Change WHERE ChangedBy = ? AND Committed = 0 AND InternalName %STARTSWITH ?"
+ set rs = ..ExecDirectNoPriv(sql,$username,productionName_"||")
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ while rs.%Next() {
+ set items(rs.InternalName) = rs.Action
+ }
+ quit $$$OK
+}
+
+/// Executes a SQL query without privilege checking if possible on this IRIS version
+ClassMethod ExecDirectNoPriv(sql, args...) As %SQL.StatementResult
+{
+ // once minimum version is IRIS 2021.1.3, remove and just use %ExecDirectNoPriv
+ try {
+ set rs = ##class(%SQL.Statement).%ExecDirectNoPriv(,sql,args...)
+ } catch err {
+ set rs = ##class(%SQL.Statement).%ExecDirect(,sql,args...)
+ }
+ return rs
+}
+
+/// Returns value the Major.Minor version for this instance, so it can be used in comparison code which makes sure certain features are used in appropriate versions
+ClassMethod InstanceVersion() As %Numeric [ CodeMode = expression ]
+{
+$P($SYSTEM.Version.GetNumber(),".",1,2)
+}
+
+}
diff --git a/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls b/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls
index 079aba4a..5281f01d 100644
--- a/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls
+++ b/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls
@@ -17,16 +17,20 @@ Method OnPull() As %Status
if ((internalName = "") && (..ModifiedFiles(i).changeType '= "D")) {
write !, ..ModifiedFiles(i).externalName, " was not imported into the database and will not be compiled. "
} elseif (..ModifiedFiles(i).changeType = "D") {
- set delSC = ..DeleteFile(internalName)
+ set delSC = ..DeleteFile(internalName, ..ModifiedFiles(i).externalName)
if delSC {
write !, ..ModifiedFiles(i).externalName, " was deleted."
} else {
write !, "WARNING: Deletion of ", ..ModifiedFiles(i).externalName, " failed."
}
} else {
- set compilelist(internalName) = ""
set nFiles = nFiles + 1
- set sc = $$$ADDSC(sc,##class(SourceControl.Git.Utils).ImportItem(internalName, 1))
+ if (##class(SourceControl.Git.Utils).Type(internalName) = "ptd") {
+ set ptdList(internalName) = ""
+ } else {
+ set compilelist(internalName) = ""
+ set sc = $$$ADDSC(sc,##class(SourceControl.Git.Utils).ImportItem(internalName, 1))
+ }
}
}
@@ -35,6 +39,12 @@ Method OnPull() As %Status
quit $$$OK
}
set sc = $$$ADDSC(sc,$system.OBJ.CompileList(.compilelist, "ck"))
+ // after compilation, deploy any PTD items
+ set key = $order(ptdList(""))
+ while (key '= "") {
+ set sc = $$$ADDSC(sc, ##class(SourceControl.Git.Utils).ImportItem(key,1))
+ set key = $order(ptdList(key))
+ }
if $$$comClassDefined("Ens.Director") && ##class(Ens.Director).IsProductionRunning() {
write !,"Updating production... "
set sc = $$$ADDSC(sc,##class(Ens.Director).UpdateProduction())
@@ -43,21 +53,35 @@ Method OnPull() As %Status
quit sc
}
-Method DeleteFile(item As %String) As %Status
-{
+Method DeleteFile(item As %String = "", externalName As %String = "") As %Status
+{
try {
set sc = $$$OK
- set type = ##class(SourceControl.Git.Utils).Type(item)
+ set type = $select(
+ ##class(SourceControl.Git.Util.Production).ItemIsPTD(externalName): "ptd",
+ 1: ##class(SourceControl.Git.Utils).Type(item)
+ )
set name = ##class(SourceControl.Git.Utils).NameWithoutExtension(item)
+ set settings = ##class(SourceControl.Git.Settings).%New()
set deleted = 1
if type = "prj" {
set sc = $system.OBJ.DeleteProject(name)
}elseif type = "cls" {
- set sc = $system.OBJ.Delete(item)
+ if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(name) {
+ write !, "Production decomposition enabled, skipping delete of production class"
+ } else {
+ set sc = $system.OBJ.Delete(item)
+ }
}elseif $listfind($listbuild("mac","int","inc","bas","mvb","mvi"), type) > 0 {
set sc = ##class(%Routine).Delete(item)
}elseif type = "csp" {
set sc = $System.CSP.DeletePage(item)
+ } elseif settings.decomposeProductions && (type = "ptd") {
+ set normalizedFilePath = ##class(%File).NormalizeFilename(##class(SourceControl.Git.Utils).TempFolder()_externalName)
+ set sc = ##class(%SYSTEM.Status).AppendStatus(
+ ##class(SourceControl.Git.Production).RemoveItemByExternalName(normalizedFilePath,"FullExternalName"),
+ ##class(%Library.RoutineMgr).Delete(item)
+ )
}elseif ##class(SourceControl.Git.Utils).UserTypeCached(item) {
set sc = ##class(%Library.RoutineMgr).Delete(item)
} else {
@@ -65,8 +89,10 @@ Method DeleteFile(item As %String) As %Status
}
if deleted && $$$ISOK(sc) {
- do ##class(SourceControl.Git.Utils).RemoveRoutineTSH(item)
- kill $$$TrackedItems(##class(SourceControl.Git.Utils).NormalizeExtension(item))
+ if (item '= "") {
+ do ##class(SourceControl.Git.Utils).RemoveRoutineTSH(item)
+ kill $$$TrackedItems(##class(SourceControl.Git.Utils).NormalizeExtension(item))
+ }
} else {
if +$system.Status.GetErrorCodes(sc) = $$$ClassDoesNotExist {
// if something we wanted to delete is already deleted -- good!
diff --git a/cls/SourceControl/Git/Settings.cls b/cls/SourceControl/Git/Settings.cls
index 75e3da2f..2171b9f0 100644
--- a/cls/SourceControl/Git/Settings.cls
+++ b/cls/SourceControl/Git/Settings.cls
@@ -23,6 +23,12 @@ Property percentClassReplace As %String [ InitialExpression = {##class(SourceCon
/// Git project settings are read-only in the web user interface
Property settingsUIReadOnly As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).SettingsUIReadOnly()} ];
+/// Interoperability productions are source-controlled under separate files for each configuration item
+Property decomposeProductions As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).DecomposeProductions()} ];
+
+/// Allow editing a decomposed production class in an IDE
+Property decomposeProdAllowIDE As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).DecomposeProdAllowIDE()} ];
+
/// Attribution: Git username for user ${username}
Property gitUserName As %String(MAXLEN = 255) [ InitialExpression = {##class(SourceControl.Git.Utils).GitUserName()} ];
@@ -75,6 +81,36 @@ Method %OnNew() As %Status
quit $$$OK
}
+Method SaveWithSourceControl() As %Status
+{
+ set sc = $$$OK
+ #dim %SourceControl As %Studio.SourceControl.Interface
+ if '$isobject($get(%SourceControl)) {
+ new %SourceControl
+ do ##class(%Studio.SourceControl.Interface).SourceControlCreate($username)
+ }
+ set gitDir = ##class(%File).NormalizeDirectory(..namespaceTemp)_".git"
+ set skipSourceControl = '##class(%File).DirectoryExists(gitDir)
+ $$$QuitOnError(..%Save())
+ set internalName = ##class(SourceControl.Git.Settings.Document).#INTERNALNAME
+ set settingsDoc = ##class(SourceControl.Git.Settings.Document).%New(internalName)
+ $$$QuitOnError(settingsDoc.Load()) // reload doc to update timestamps
+ if 'skipSourceControl {
+ // Source control settings naively by only calling OnAfterSave hooks
+ // Future enhancement: add full source control support with settings UI
+ if (##class(%Studio.SourceControl.Interface).SourceControlClassGet() = ##class(SourceControl.Git.Extension).%ClassName(1)) {
+ if '##class(SourceControl.Git.Utils).IsInSourceControl(internalName) {
+ set sc = ##class(SourceControl.Git.Utils).AddToSourceControl(internalName)
+ $$$QuitOnError(sc)
+ }
+ }
+ if ($IsObject($get(%SourceControl))) {
+ $$$QuitOnError(%SourceControl.OnAfterSave(internalName))
+ }
+ }
+ quit sc
+}
+
Method %Save() As %Status
{
set sc = ..%ValidateObject()
@@ -133,6 +169,12 @@ Method %Save() As %Status
// update value of basicUserMode to reflect the updated setting for basicMode
set ..userBasicMode = ##class(SourceControl.Git.Utils).UserBasicMode()
+ set @storage@("settings","decomposeProductions") = ..decomposeProductions
+ if (..decomposeProductions & ($data(..Mappings("PTD"))<10)) {
+ set ..Mappings("PTD","*") = "ptd/" // configure a default mapping for PTD items
+ }
+ set @storage@("settings","decomposeProdAllowIDE") = ..decomposeProdAllowIDE
+
kill @##class(SourceControl.Git.Utils).MappingsNode()
merge @##class(SourceControl.Git.Utils).MappingsNode() = ..Mappings
@@ -141,6 +183,53 @@ Method %Save() As %Status
quit $$$OK
}
+Method ToDynamicObject() As %DynamicObject
+{
+ // uses custom methods rather than %JSON.Adaptor because Mappings multidimensional
+ // array is not supported
+ set settingsJSON = {
+ "pullEventClass": (..pullEventClass),
+ "percentClassReplace": (..percentClassReplace),
+ "Mappings": {}
+ }
+ do settingsJSON.%Set("decomposeProductions",..decomposeProductions,"boolean")
+ set k1 = $order(..Mappings(""))
+ while (k1 '= "") {
+ do settingsJSON.Mappings.%Set(k1, {})
+ set k2 = $order(..Mappings(k1,""))
+ while (k2 '= "") {
+ do settingsJSON.Mappings.%Get(k1).%Set(k2,{"directory": (..Mappings(k1,k2))})
+ if $get(..Mappings(k1,k2,"NoFolders")) {
+ do settingsJSON.Mappings.%Get(k1).%Get(k2).%Set("noFolders", 1, "boolean")
+ }
+ set k2 = $order(..Mappings(k1, k2))
+ }
+ set k1 = $order(..Mappings(k1))
+ }
+ return settingsJSON
+}
+
+Method ImportDynamicObject(pSettingsDyn As %DynamicObject)
+{
+ set ..pullEventClass = pSettingsDyn.%Get("pullEventClass")
+ set ..percentClassReplace = pSettingsDyn.%Get("percentClassReplace")
+ set ..decomposeProductions = pSettingsDyn.%Get("decomposeProductions")
+ kill ..Mappings
+ set mappingsDyn = pSettingsDyn.%Get("Mappings", {})
+ set i1 = mappingsDyn.%GetIterator()
+ while i1.%GetNext(.k1, .v1) {
+ set i2 = v1.%GetIterator()
+ while i2.%GetNext(.k2, .v2) {
+ set ..Mappings(k1, k2) = v2.%Get("directory")
+ if v2.%Get("noFolders") {
+ set ..Mappings(k1, k2, "NoFolders") = 1
+ } else {
+ kill ..Mappings(k1, k2, "NoFolders")
+ }
+ }
+ }
+}
+
ClassMethod CreateNamespaceTempFolder() As %Status
{
set storage = ##class(SourceControl.Git.Utils).#Storage
@@ -154,6 +243,7 @@ ClassMethod CreateNamespaceTempFolder() As %Status
ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ]
{
do %code.WriteLine(" set inst = ..%New()")
+ do %code.WriteLine(" do inst.RetrieveDefaults()")
set defaultPromptFlag = $$$DisableBackupCharMask + $$$TrapCtrlCMask + $$$EnableQuitCharMask + $$$DisableHelpCharMask + $$$DisableHelpContextCharMask + $$$TrapErrorMask
set property = ""
for {
@@ -217,7 +307,7 @@ ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ]
do %code.WriteLine(" set inst."_property_" = value")
}
- do %code.WriteLine(" $$$ThrowOnError(inst.%Save())")
+ do %code.WriteLine(" $$$ThrowOnError(inst.SaveWithSourceControl())")
do %code.WriteLine(" write !,""Settings saved.""")
do %code.WriteLine(" do inst.OnAfterConfigure()")
do %code.WriteLine(" quit 1")
@@ -344,8 +434,6 @@ Method OnAfterConfigure() As %Boolean
set workMgr = $System.WorkMgr.%New("")
$$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init"))
$$$ThrowOnError(workMgr.WaitForComplete())
-
- do ##class(SourceControl.Git.Utils).EmptyInitialCommit()
} elseif (value = 2) {
set response = ##class(%Library.Prompt).GetString("Git remote URL (note: if authentication is required, use SSH, not HTTPS):",.remote,,,,defaultPromptFlag)
if (response '= $$$SuccessResponse) {
@@ -358,6 +446,8 @@ Method OnAfterConfigure() As %Boolean
set workMgr = $System.WorkMgr.%New("")
$$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Clone",remote))
$$$ThrowOnError(workMgr.WaitForComplete())
+ // export settings file without committing
+ $$$ThrowOnError(..SaveWithSourceControl())
}
}
}
@@ -394,4 +484,25 @@ Method ConfigureBinPath(ByRef path As %String) As %Boolean
return 1
}
+Method RetrieveDefaults() As %Boolean
+{
+ do ##class(%zpkg.isc.sc.git.Defaults).GetDefaultSettings(.settings)
+ set iterator = settings.%GetIterator()
+ while iterator.%GetNext(.key, .value) {
+ set $property($this,key) = value
+ }
+ return $$$OK
+}
+
+Method SaveDefaults() As %Boolean
+{
+ set defaults = {}
+ set items = $lb("gitBinPath", "pullEventClass", "percentClassReplace", "environmentName", "systemBasicMode", "defaultMergeBranch", "mappedItemsReadOnly", "compileOnImport")
+ for i=1:1:$LISTLENGTH(items) {
+ set property = $listget(items,i)
+ do defaults.%Set(property, $property($this, property))
+ }
+ return ##class(%zpkg.isc.sc.git.Defaults).SetDefaultSettings(defaults)
+}
+
}
diff --git a/cls/SourceControl/Git/Settings/Document.cls b/cls/SourceControl/Git/Settings/Document.cls
new file mode 100644
index 00000000..896be064
--- /dev/null
+++ b/cls/SourceControl/Git/Settings/Document.cls
@@ -0,0 +1,108 @@
+/// Custom studio document type for Embedded Git settings that are controlled by a file
+Class SourceControl.Git.Settings.Document Extends %Studio.AbstractDocument
+{
+
+Projection RegisterExtension As %Projection.StudioDocument(DocumentExtension = "GSC", DocumentNew = 0, DocumentType = "json");
+
+Parameter INTERNALNAME = "embedded-git-config.GSC";
+
+Parameter EXTERNALNAME = "embedded-git-config.json";
+
+/// Return 1 if the routine 'name' exists and 0 if it does not.
+ClassMethod Exists(name As %String) As %Boolean
+{
+ return (name = ..#INTERNALNAME)
+}
+
+/// Load the routine in Name into the stream Code
+Method Load() As %Status
+{
+ set sc = $$$OK
+ try {
+ set stream = ..GetCurrentStream()
+ $$$ThrowOnError(..Code.CopyFromAndSave(stream))
+ $$$ThrowOnError(..Code.Rewind())
+ do ..UpdateHash(stream)
+ } catch err {
+ set sc = err.AsStatus()
+ }
+ return sc
+}
+
+Method GetCurrentStream() As %Stream.Object
+{
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ set dynObj = settings.ToDynamicObject()
+ set formatter = ##class(%JSON.Formatter).%New()
+ $$$ThrowOnError(formatter.FormatToStream(dynObj, .stream))
+ return stream
+}
+
+/// Save the routine stored in Code
+Method Save() As %Status
+{
+ set sc = $$$OK
+ try {
+ try {
+ set settingsJSON = ##class(%DynamicObject).%FromJSON(..Code)
+ } catch err {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, "Invalid JSON"))
+ }
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ do settings.ImportDynamicObject(settingsJSON)
+ set sc = settings.%Save()
+ quit:$$$ISERR(sc)
+ } catch err {
+ set sc = err.AsStatus()
+ }
+ return sc
+}
+
+ClassMethod ListExecute(ByRef qHandle As %Binary, Directory As %String, Flat As %Boolean, System As %Boolean) As %Status
+{
+ if $g(Directory)'="" {
+ set qHandle=""
+ quit $$$OK
+ }
+ set qHandle = $listbuild(1,"")
+ quit $$$OK
+}
+
+ClassMethod ListFetch(ByRef qHandle As %Binary, ByRef Row As %List, ByRef AtEnd As %Integer = 0) As %Status [ PlaceAfter = ListExecute ]
+{
+ set Row="", AtEnd=0
+ set rownum = $lg(qHandle,1)
+ if rownum'=1 {
+ set AtEnd = 1
+ } else {
+ set Row = $listbuild(..#INTERNALNAME,$zts-5,0,"")
+ set $list(qHandle,1) = 2
+ }
+ quit $$$OK
+}
+
+ClassMethod ListClose(ByRef qHandle As %Binary) As %Status [ PlaceAfter = ListExecute ]
+{
+ set qHandle = ""
+ quit $$$OK
+}
+
+Method UpdateHash(stream)
+{
+ set stream = $Get(stream,..GetCurrentStream())
+ set hash = $System.Encryption.SHA1HashStream(stream)
+ if $get(@##class(SourceControl.Git.Utils).#Storage@("settings","Hash")) '= hash {
+ set @##class(SourceControl.Git.Utils).#Storage@("settings","Hash") = hash
+ set @##class(SourceControl.Git.Utils).#Storage@("settings","TS") = $zdatetime($h,3)
+ }
+}
+
+/// Return the timestamp of routine 'name' in %TimeStamp format. This is used to determine if the routine has
+/// been updated on the server and so needs reloading from Studio. So the format should be $zdatetime($horolog,3),
+/// or "" if the routine does not exist.
+ClassMethod TimeStamp(name As %String) As %TimeStamp
+{
+ return $get(@##class(SourceControl.Git.Utils).#Storage@("settings","TS"), $zdatetime($h,3))
+}
+
+}
diff --git a/cls/SourceControl/Git/Util/Production.cls b/cls/SourceControl/Git/Util/Production.cls
new file mode 100644
index 00000000..a6692601
--- /dev/null
+++ b/cls/SourceControl/Git/Util/Production.cls
@@ -0,0 +1,69 @@
+Include SourceControl.Git
+
+/// Contains utilities for production decomposition that are specific to git-source-control
+Class SourceControl.Git.Util.Production
+{
+
+/// Baselines all productions in this namespace from single-file to decomposed or vice versa.
+ClassMethod BaselineProductions()
+{
+ set st = $$$OK
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ set rs = ##class(%Dictionary.ClassDefinition).SubclassOfFunc("Ens.Production")
+ throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message)
+ while rs.%Next(.sc) {
+ $$$ThrowOnError(sc)
+ set productionName = rs.Name
+ set productionInternalName = productionName _ ".cls"
+ if '##class(SourceControl.Git.Utils).FileIsMapped(productionInternalName) {
+ if settings.decomposeProductions {
+ write !, "Decomposing production: " _ productionInternalName
+ if ##class(SourceControl.Git.Utils).IsInSourceControl(productionInternalName) {
+ set st = ##class(SourceControl.Git.Utils).RemoveFromSourceControl(productionInternalName)
+ $$$ThrowOnError(st)
+ }
+ set st = ##class(SourceControl.Git.Production).ExportProductionDefinitionShards(productionName,"FullExternalName",.itemInternalNames)
+ $$$ThrowOnError(st)
+ set key = $order(itemInternalNames(""))
+ while (key '= "") {
+ set st = ##class(SourceControl.Git.Utils).AddToSourceControl(key)
+ $$$ThrowOnError(st)
+ set key = $order(itemInternalNames(key))
+ }
+ } else {
+ write !, "Recomposing production: " _ productionInternalName
+ set st = ##class(SourceControl.Git.Utils).AddToSourceControl(productionInternalName)
+ $$$ThrowOnError(st)
+ set key = $order(@##class(SourceControl.Git.Utils).#Storage@("items", ""))
+ while (key '= "") {
+ if $match(key,"^"_productionName_"\|\|.*\.(?i)ptd$") {
+ set st = ##class(SourceControl.Git.Utils).RemoveFromSourceControl(key)
+ $$$ThrowOnError(st)
+ }
+ set key = $order(@##class(SourceControl.Git.Utils).#Storage@("items", key))
+ }
+ }
+ }
+ }
+}
+
+/// Determines whether an item has type PTD based on the external name, not reliant on the file existing
+ClassMethod ItemIsPTD(externalName) As %Boolean
+{
+ if $zconvert($piece(externalName,".",*),"l") '= "xml" {
+ return 0
+ }
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ set normFilePath = $replace(externalName,"\","/")
+ set key = $order($$$SourceMapping("PTD",""))
+ while (key '= "") {
+ set directory = $replace($$$SourceMapping("PTD",key), "\","/")
+ if $find(normFilePath, directory) = ($length(directory) + 1) {
+ return 1
+ }
+ set key = $order($$$SourceMapping("PTD",key))
+ }
+ return 0
+}
+
+}
diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls
index ff5aeb44..bb569f35 100644
--- a/cls/SourceControl/Git/Utils.cls
+++ b/cls/SourceControl/Git/Utils.cls
@@ -72,6 +72,16 @@ ClassMethod SettingsUIReadOnly() As %Status [ CodeMode = expression ]
$Get(@..#Storage@("settings","settingsUIReadOnly"), 0)
}
+ClassMethod DecomposeProductions() As %Boolean [ CodeMode = expression ]
+{
+$Get(@..#Storage@("settings","decomposeProductions"), 0)
+}
+
+ClassMethod DecomposeProdAllowIDE() As %Boolean [ CodeMode = expression ]
+{
+$Get(@..#Storage@("settings","decomposeProdAllowIDE"), 0)
+}
+
ClassMethod FavoriteNamespaces() As %String
{
set favNamespaces = []
@@ -361,8 +371,9 @@ ClassMethod Init() As %Status
{
do ..RunGitCommand("init",.errStream,.outStream)
do ..PrintStreams(outStream, errStream)
-
- quit $$$OK
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ $$$QuitOnError(settings.SaveWithSourceControl())
+ quit ..Commit(##class(SourceControl.Git.Settings.Document).#INTERNALNAME,"initial commit")
}
ClassMethod Revert(InternalName As %String) As %Status
@@ -770,7 +781,9 @@ ClassMethod AddToSourceControl(InternalName As %String) As %Status
}
for i=1:1:$Get(filenames) {
- set FileInternalName = ##class(SourceControl.Git.Utils).NormalizeExtension(##class(SourceControl.Git.Utils).NameToInternalName(filenames(i), 0,,1))
+ set ignoreNonexistent = (type '= "ptd")
+ set FileInternalName = ##class(SourceControl.Git.Utils).NormalizeExtension(
+ ##class(SourceControl.Git.Utils).NameToInternalName(filenames(i), 0,ignoreNonexistent,1))
if (FileInternalName = "") {
continue
}
@@ -1017,7 +1030,8 @@ ClassMethod IsCspFolder(InternalName As %String) As %Boolean
/// pkg -- package
/// prj -- project
/// csp -- csp-page or csp-folder. See IsCspFolder
-/// csp -- any static file from csp-folder
+/// csp -- any static file from csp-folder
+/// ptd -- interoperability production configuration item
ClassMethod Type(InternalName As %String) As %String
{
#dim extension as %String = $zconvert($piece(InternalName,".",$length(InternalName,".")),"L")
@@ -1377,8 +1391,28 @@ ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose A
#dim fileTSH = ##class(%File).GetFileDateModified(filename)
#dim sc as %Status = $$$OK
- if ..IsRoutineOutdated(InternalName) || force {
- if ..UserTypeCached(InternalName,.docclass,.doctype) {
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ set type = ..Type(InternalName)
+ set imported = 1
+ if ..IsRoutineOutdated(InternalName) || force || (type = "ptd"){
+ if (type = "ptd") && settings.decomposeProductions && ##class(%Library.EnsembleMgr).IsEnsembleNamespace() {
+ if ##class(%File).Exists(filename) {
+ // Deployment manager should not reexport because studio project file includes timestamp
+ // ideally we could just new %SourceControl, but Ens portal config pages do not use %SourceControl
+ new %gscSkipSaveHooks
+ set %gscSkipSaveHooks = 1
+ do ##class(SourceControl.Git.Production).ParseInternalName(InternalName,,,,,.targetProduction)
+ if (targetProduction '= "") && '$$$comClassDefined(targetProduction) {
+ set sc = ##class(SourceControl.Git.Production).CreateProduction(targetProduction)
+ }
+ if $$$ISOK(sc) {
+ set sc = ##class(SourceControl.Git.Production).ImportPTD(filename, targetProduction)
+ }
+ }
+ } elseif ..ItemIsProductionToDecompose(InternalName) {
+ write !, "Production decomposition enabled, skipping import of production class"
+ set imported = 0
+ } elseif ..UserTypeCached(InternalName,.docclass,.doctype) {
set routineMgr = ##class(%RoutineMgr).%OpenId(InternalName)
do routineMgr.Code.Rewind()
set source = ##class(%Stream.FileCharacter).%OpenId(filename,,.sc)
@@ -1393,12 +1427,14 @@ ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose A
set sc = $system.OBJ.Load(filename,$Select(compile:"ck-l",1:"-l-d"))
}
}
- if sc {
- set sc = ..UpdateRoutineTSH(InternalName, fileTSH)
- if ..Type(InternalName) = "prj" {
- set sc = $$$ADDSC(sc, ..FixProjectCspReferences(InternalName))
+ if $$$ISOK(sc) {
+ if imported {
+ set sc = ..UpdateRoutineTSH(InternalName, fileTSH)
+ if type = "prj" {
+ set sc = $$$ADDSC(sc, ..FixProjectCspReferences(InternalName))
+ }
+ write !, InternalName," has been imported from ", filename
}
- write !, InternalName," has been imported from ", filename
} else {
write !, "ERROR importing" ,InternalName, !
do $system.Status.DisplayError(sc)
@@ -1426,6 +1462,24 @@ ClassMethod ImportCSPFile(InternalName As %String) As %Status
Quit sc
}
+ClassMethod ListItemsRecursively(type, fileSpec, directory, ByRef itemList) [ Private ]
+{
+ set files = ##class(%Library.File).FileSetFunc(directory,fileSpec,,1)
+ throw:files.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(files.%SQLCODE,files.%Message)
+ while files.%Next() {
+ if (files.Type="D") {
+ do ..ListItemsRecursively(type, fileSpec, files.Name, .itemList)
+ } else {
+ set internalName = files.ItemName
+ if ($zconvert(type,"l") = "ptd") {
+ do ##class(SourceControl.Git.Production).ParseExternalName($translate(files.Name,"\","/"), .internalName)
+ }
+ set itemList(internalName) = ""
+ }
+ }
+}
+
+/// Returns an array of internal names of all items in the local source control repository.
ClassMethod ListItemsInFiles(ByRef itemList, ByRef err) As %Status
{
#define DoNotLoad 1
@@ -1442,13 +1496,11 @@ ClassMethod ListItemsInFiles(ByRef itemList, ByRef err) As %Status
set mappedFilePath = ##class(%File).NormalizeFilename(mappedRelativePath, ..TempFolder())
if (##class(%File).DirectoryExists(mappedFilePath)){
- if ..UserTypeCached("foo."_mappingFileType) {
- set fileSpec = "*."_$zcvt(mappingFileType,"L")_";*."_$zconvert(mappingFileType,"U")
- set files = ##class(%Library.File).FileSetFunc(mappedFilePath,fileSpec)
- while files.%Next() {
- // Assumes flat file structure
- set itemList(files.ItemName) = ""
- }
+ if ..UserTypeCached("foo."_mappingFileType, .userTypeClass) {
+ set fileSpec = $select(
+ userTypeClass="Ens.Util.ProjectTextDocument": "*.xml;*.XML",
+ 1: "*."_$zcvt(mappingFileType,"L")_";*."_$zconvert(mappingFileType,"U"))
+ do ..ListItemsRecursively(mappingFileType, fileSpec, mappedFilePath, .itemList)
} else {
set res = $system.OBJ.ImportDir(mappedFilePath,,"-d",.err,1, .tempItemList, $$$DoNotLoad)
merge itemList = tempItemList
@@ -1604,7 +1656,7 @@ ClassMethod ExportItem(InternalName As %String, expand As %Boolean = 1, force As
}elseif (type = "csp") && ..IsCspFolder(InternalName) {
$$$QuitOnError(..ExportRoutinesAux(InternalName , "/", 0, force, .filenames))
}else {
- if ..IsTempFileOutdated(InternalName) || force {
+ if (type = "ptd") || ..IsTempFileOutdated(InternalName) || force {
#dim filename as %String = ..FullExternalName(InternalName, .MappingExists)
if (MappingExists = 0){
write "Did not find a matching mapping for """_InternalName_""". Skipping export."
@@ -1616,18 +1668,39 @@ ClassMethod ExportItem(InternalName As %String, expand As %Boolean = 1, force As
write "Mapping to another database found. Skipping export"
quit $$$OK
}
- set filenames($I(filenames)) = filename
write !, "exporting new version of ", InternalName, " to ", filename
- $$$QuitOnError($system.OBJ.ExportUDL(InternalName, filename,"-d/diff"))
- $$$QuitOnError(..UpdateRoutineTSH(InternalName, $h))
- if '##class(SourceControl.Git.Change).IsUncommitted(filename) {
- $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "", 0))
+ if (type = "ptd") {
+ $$$QuitOnError(##class(SourceControl.Git.Production).ExportPTD(InternalName,"FullExternalName"))
+ } elseif (..ItemIsProductionToDecompose(InternalName, .productionName)) {
+ write !, "Production decomposition enabled, skipping export of production class"
+ set filename = ""
+ } else {
+ $$$QuitOnError($system.OBJ.ExportUDL(InternalName, filename,"-d/diff"))
+ }
+ if (filename '= "") && ##class(%File).Exists(filename) {
+ set filenames($I(filenames)) = filename
+ $$$QuitOnError(..UpdateRoutineTSH(InternalName, $h))
+ if '##class(SourceControl.Git.Change).IsUncommitted(filename) {
+ $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "", 0))
+ }
}
}
}
quit $$$OK
}
+ClassMethod ItemIsProductionToDecompose(InternalName, Output productionName)
+{
+ set settings = ##class(SourceControl.Git.Settings).%New()
+ set name = $piece(InternalName,".",1,*-1)
+ set decomposeProduction = settings.decomposeProductions && (..Type(InternalName) = "cls")
+ && ##class(SourceControl.Git.Production).IsProductionClass(name, "FullExternalName")
+ if decomposeProduction {
+ set productionName = name
+ }
+ return decomposeProduction
+}
+
ClassMethod ExportProject(project As %String, force As %Boolean = 0, ByRef filenames) As %Status
{
#dim rs as %ResultSet = ##class(%ResultSet).%New("%Studio.Project:ProjectItemsList")
@@ -2181,7 +2254,7 @@ ClassMethod GitStatus(ByRef files, IncludeAllFiles = 0)
while $listnext(list, pointer, item) {
set operation = $zstrip($extract(item, 1, 2), "
+