Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.vscode/
ptd/
.gitattributes
*.code-workspace
*.code-workspace
.claude/worktrees/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.18.0] - Unreleased

### Added
- Record map classes and their generated data classes are now automatically added to source control (#955)

## [2.17.1] - 2026-08-18

### Fixed
Expand Down
21 changes: 12 additions & 9 deletions cls/SourceControl/Git/Extension.cls
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ Method UserAction(Type As %Integer, Name As %String, InternalName As %String, Se
do ##class(SourceControl.Git.Change).GetUncommitted(filename,.tAction)
do ..GetStatus(.InternalName, .isInSourceControl, .isEditable,.isCheckedOut,.userCheckedOut)

// Deal with Business Processes and Rules
// Note: Business Processes and Rules do not have a 'new document' User Action, and thus must be added like this
// certain portal UIs create and compile classes without going through the 'new document' user

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: capitalization

// action hooks. we add them explicitly here instead.
if (('isInSourceControl)) {
do ..CheckBusinessProcessesAndRules(InternalName)
do ..CheckClassesToAddExplicitly(InternalName)
}

if '$data(tAction) {
Expand Down Expand Up @@ -404,9 +404,11 @@ Method OnAfterSave(InternalName As %String, Object As %RegisteredObject = {$$$NU
if '##class(SourceControl.Git.Change).IsUncommitted(filename) {
$$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0))
}
// If this is a RecordMap, keep its generated data class under source control too.
$$$ThrowOnError(##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(InternalName, 1))
}
} else {
do ..CheckBusinessProcessesAndRules(InternalName)
do ..CheckClassesToAddExplicitly(InternalName)
}
}
} catch e {
Expand Down Expand Up @@ -593,17 +595,18 @@ Method CheckCommitterIdentity(Settings As SourceControl.Git.Settings, ByRef Acti
return 0
}

/// Deal with Business Processes and Rules
Method CheckBusinessProcessesAndRules(InternalName As %String) As %Status
/// Add classes whose editors don't respect the 'new document' User Action, so we can't rely on the
/// normal add-on-create flow for them.
Method CheckClassesToAddExplicitly(InternalName As %String) As %Status
{
// Note: Business Processes and Rules are not added through normal user action processes because of upstream hook issues,
// so we have to add them like this
// Business Processes, Business Rules, and Record Maps handled here
if (##class(SourceControl.Git.Utils).Type(InternalName) = "cls") {
set name = $piece(InternalName,".CLS",1)
if '##class(%Dictionary.CompiledClass).%ExistsId(name) quit
if '$system.CLS.IsMthd(name, "%Extends") quit
try {
if ($classmethod(name,"%Extends","Ens.BusinessProcess") || $classmethod(name,"%Extends","Ens.Rule.Definition")) {
if ($classmethod(name,"%Extends","Ens.BusinessProcess") || $classmethod(name,"%Extends","Ens.Rule.Definition")
|| $classmethod(name,"%Extends","EnsLib.RecordMap.RecordMap")) {
do ..AddToSourceControl(InternalName)
}
} catch err {
Expand Down
72 changes: 72 additions & 0 deletions cls/SourceControl/Git/Util/RecordMap.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
Include (%occStatus, %occErrors, SourceControl.Git)

/// Utilities for keeping the classes generated by a RecordMap (its data/target class and any batch
/// class) under source control alongside the RecordMap itself.
Class SourceControl.Git.Util.RecordMap
{

/// Ensure the generated data/target class(es) of a RecordMap are under source control. For each
/// related class reported by <method>GetRelatedClasses</method> that is not already tracked, add it.
/// Used both when a RecordMap is first added and when it is saved/compiled, so its data class always
/// comes along. Does nothing for non-RecordMap items.
ClassMethod AddRelatedClasses(InternalName As %String, refreshUncommitted As %Boolean = 1) As %Status
{
set ec = $$$OK
do ..GetRelatedClasses(InternalName, .relatedClasses)
set added = 0
set relatedKey = $order(relatedClasses(""))
while (relatedKey '= "") {
if '##class(SourceControl.Git.Utils).IsInSourceControl(relatedKey) {
set sc = ##class(SourceControl.Git.Utils).AddToSourceControl(relatedKey, 0)
set added = 1
if 'sc {
set ec = $$$ADDSC(ec, sc)
}
}
set relatedKey = $order(relatedClasses(relatedKey))
}
if added && refreshUncommitted {
do ##class(SourceControl.Git.Change).RefreshUncommitted(,,,1)
}
quit ec
}

/// For a RecordMap class (one that extends EnsLib.RecordMap.RecordMap), report the generated
/// data/target class(es) that should be source-controlled alongside it. These classes are not
/// regenerated by recompiling the RecordMap, so they must be tracked independently.
/// <var>related</var> is returned subscripted by the internal name of each related class.
ClassMethod GetRelatedClasses(InternalName As %String, Output related) As %Status
{
kill related
set sc = $$$OK
try {
if (##class(SourceControl.Git.Utils).Type(.InternalName) '= "cls") quit
if '##class(%Library.EnsembleMgr).IsEnsembleNamespace() quit
set className = $piece(##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName), ".CLS", 1)
if '##class(%Dictionary.CompiledClass).%ExistsId(className) quit
if '$system.CLS.IsMthd(className, "%Extends") quit
if '$classmethod(className, "%Extends", "EnsLib.RecordMap.RecordMap") quit

// GetGeneratedClasses reports the generated classes (target, batch, etc.) plus the RecordMap
// itself; the RecordMap is excluded below. It is unioned with the OBJECTNAME parameter (the
// target class), which acts as a fallback for when GetGeneratedClasses is unreliable.
// GetGeneratedClasses kills its output array, so the OBJECTNAME set must stay after the call.
kill candidates
do $classmethod(className, "GetGeneratedClasses", .candidates)
set objectName = $parameter(className, "OBJECTNAME")
if (objectName '= "") set candidates(objectName) = ""

set candidate = $order(candidates(""))
while (candidate '= "") {
if (candidate '= className) && $$$defClassDefined(candidate) {
set related(candidate _ ".CLS") = ""
}
set candidate = $order(candidates(candidate))
}
} catch e {
set sc = e.AsStatus()
}
quit sc
}

}
7 changes: 7 additions & 0 deletions cls/SourceControl/Git/Utils.cls
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,13 @@ ClassMethod AddToSourceControl(InternalName As %String, refreshUncommitted As %B
do ..PrintStreams(outStream, errStream)

}

// If this is a RecordMap class, also add its generated data/target class(es), which the
// developer would otherwise have to remember to add manually. AddRelatedClasses refreshes
// the uncommitted list itself below, so skip the per-item refresh here.
if (type = "cls") {
set ec = $$$ADDSC(ec, ##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(item, 0))
}
}
if refreshUncommitted {
do ##class(SourceControl.Git.Change).RefreshUncommitted(,,,1)
Expand Down
2 changes: 1 addition & 1 deletion module.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<Document name="git-source-control.ZPM">
<Module>
<Name>git-source-control</Name>
<Version>2.17.1</Version>
<Version>2.18.0</Version>
<Description>Server-side source control extension for use of Git on InterSystems platforms</Description>
<Keywords>git source control studio vscode</Keywords>
<Packaging>module</Packaging>
Expand Down
200 changes: 200 additions & 0 deletions test/UnitTest/SourceControl/Git/RecordMap.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
Import SourceControl.Git

/// Tests for automatically source-controlling the data/target classes generated by a RecordMap.
Class UnitTest.SourceControl.Git.RecordMap Extends UnitTest.SourceControl.Git.AbstractTest
{

Parameter FIXTUREPACKAGE = "UnitTest.SourceControl.Git.RecordMapFixture";

Method %OnClose() As %Status [ Private, ServerOnly = 1 ]
{
do $system.OBJ.DeletePackage(..#FIXTUREPACKAGE, "-d")
quit ##super()
}

/// Build a real, compiled RecordMap whose target/record class is generated as a separate class,
/// mirroring what the RecordMap editor produces. Returns the record map class name.
ClassMethod CreateRecordMap(pMap As %String, pTarget As %String) As %Status
{
do $system.OBJ.Delete(pMap, "-d")
do $system.OBJ.Delete(pTarget, "-d")
set model = ##class(EnsLib.RecordMap.Model.Record).%New()
set model.name = pMap
set model.type = "delimited"
set model.targetClassname = pTarget
set model.recordTerminator = $char(13, 10)
do model.Separators.Insert(",")
set field = ##class(EnsLib.RecordMap.Model.Field).%New()
set field.name = "Field1"
do model.Contents.Insert(field)
set sc = model.SaveToClass()
if $$$ISERR(sc) quit sc
set target = pTarget
quit ##class(EnsLib.RecordMap.Generator).GenerateObject(pMap, .target, , , 2)
}

/// Build a RecordMap associated with a (simple) Batch class, mirroring what the RecordMap editor
/// produces when a batch class is configured. The batch class must exist before generation.
ClassMethod CreateBatchRecordMap(pMap As %String, pTarget As %String, pBatch As %String) As %Status
{
do $system.OBJ.Delete(pMap, "-d")
do $system.OBJ.Delete(pTarget, "-d")
do $system.OBJ.Delete(pBatch, "-d")
set batchDef = ##class(%Dictionary.ClassDefinition).%New()
set batchDef.Name = pBatch
set batchDef.Super = "EnsLib.RecordMap.Batch"
set param = ##class(%Dictionary.ParameterDefinition).%New()
set param.Name = "RECORDMAPGENERATED"
set param.Default = 1
set param.parent = batchDef
set sc = batchDef.%Save()
if $$$ISERR(sc) quit sc
set sc = $system.OBJ.Compile(pBatch, "ck")
if $$$ISERR(sc) quit sc
set model = ##class(EnsLib.RecordMap.Model.Record).%New()
set model.name = pMap
set model.type = "delimited"
set model.targetClassname = pTarget
set model.batchClass = pBatch
set model.recordTerminator = $char(13, 10)
do model.Separators.Insert(",")
set field = ##class(EnsLib.RecordMap.Model.Field).%New()
set field.name = "Field1"
do model.Contents.Insert(field)
set sc = model.SaveToClass()
if $$$ISERR(sc) quit sc
set target = pTarget
quit ##class(EnsLib.RecordMap.Generator).GenerateObject(pMap, .target, , , 2)
}

/// Build a trivial compiled class that is not a RecordMap, for exercising the non-RecordMap path.
ClassMethod CreatePlainClass(pClass As %String) As %Status
{
do $system.OBJ.Delete(pClass, "-d")
set def = ##class(%Dictionary.ClassDefinition).%New()
set def.Name = pClass
set def.Super = "%Persistent"
set sc = def.%Save()
if $$$ISERR(sc) quit sc
quit $system.OBJ.Compile(pClass, "ck")
}

/// The resolver should report the generated target/data class as related to the RecordMap,
/// and must not report the RecordMap class itself.
Method TestResolverIncludesTargetClass()
{
set map = ..#FIXTUREPACKAGE_".Map"
set target = ..#FIXTUREPACKAGE_".Record"
$$$ThrowOnError(..CreateRecordMap(map, target))

do $$$AssertStatusOK(##class(SourceControl.Git.Util.RecordMap).GetRelatedClasses(map_".CLS", .related))
do $$$AssertTrue($data(related(target_".CLS")), "target data class should be reported as related to the RecordMap")
do $$$AssertNotTrue($data(related(map_".CLS")), "the RecordMap class itself should not be reported as related")
}

/// Adding a RecordMap class to source control should automatically add its generated data class.
Method TestAddRecordMapAddsTargetClass()
{
set map = ..#FIXTUREPACKAGE_".Map"
set target = ..#FIXTUREPACKAGE_".Record"
$$$ThrowOnError(..CreateRecordMap(map, target))

do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should not be in source control before the RecordMap is added")
$$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(map_".CLS"))
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(map_".CLS"), "RecordMap class should be in source control")
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target data class should be added to source control automatically")
}

/// A RecordMap that is already tracked but whose generated data class is not (e.g. the map was
/// added before the data class existed) should have its data class pulled back under source control
/// when the map is saved/compiled. OnAfterSave delegates this to AddRelatedClasses, which is
/// exercised directly here to avoid depending on a fully configured git working tree.
Method TestSaveTrackedRecordMapAddsUntrackedTarget()
{
set map = ..#FIXTUREPACKAGE_".Map"
set target = ..#FIXTUREPACKAGE_".Record"
$$$ThrowOnError(..CreateRecordMap(map, target))

// Precondition: the RecordMap is tracked but its generated data class is not.
$$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(map_".CLS", 0))
$$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveFromSourceControl(target_".CLS", 0))
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(map_".CLS"), "RecordMap should be tracked before save")
do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should not be tracked before save")

// Saving the RecordMap should bring its data class back under source control.
$$$ThrowOnError(##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(map_".CLS", 0))
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "saving the RecordMap should re-add its generated data class to source control")
}

/// For a RecordMap that has an associated Batch class, the resolver should report both the target
/// data class and the batch class as related. The batch class is discovered via GetGeneratedClasses
/// rather than the OBJECTNAME parameter.
Method TestResolverIncludesBatchClass()
{
set map = ..#FIXTUREPACKAGE_".BMap"
set target = ..#FIXTUREPACKAGE_".BRecord"
set batch = ..#FIXTUREPACKAGE_".BBatch"
$$$ThrowOnError(..CreateBatchRecordMap(map, target, batch))

do $$$AssertStatusOK(##class(SourceControl.Git.Util.RecordMap).GetRelatedClasses(map_".CLS", .related))
do $$$AssertTrue($data(related(target_".CLS")), "target data class should be reported as related")
do $$$AssertTrue($data(related(batch_".CLS")), "batch class should be reported as related")
do $$$AssertNotTrue($data(related(map_".CLS")), "the RecordMap class itself should not be reported as related")
}

/// A class that does not extend EnsLib.RecordMap.RecordMap has no related classes, and running the
/// resolver/adder against it is a no-op that adds nothing to source control.
Method TestNonRecordMapClassHasNoRelated()
{
set plain = ..#FIXTUREPACKAGE_".Plain"
$$$ThrowOnError(..CreatePlainClass(plain))

do $$$AssertStatusOK(##class(SourceControl.Git.Util.RecordMap).GetRelatedClasses(plain_".CLS", .related))
do $$$AssertEquals($order(related("")), "", "a non-RecordMap class should report no related classes")

do $$$AssertStatusOK(##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(plain_".CLS", 0))
do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(plain_".CLS"), "a non-RecordMap class should not be added to source control")
}

/// A newly compiled RecordMap that isn't yet tracked (e.g. just generated via the RecordMap wizard,
/// which never issues a "new document" add) should be picked up by CheckClassesToAddExplicitly, the
/// same fallback that already exists for classes created by other portal UIs like the Business
/// Process/Rule editors. This should also bring along the generated target class via the
/// AddToSourceControl cascade.
Method TestUntrackedRecordMapIsAddedOnCompile()
{
set map = ..#FIXTUREPACKAGE_".CMap"
set target = ..#FIXTUREPACKAGE_".CRecord"
$$$ThrowOnError(..CreateRecordMap(map, target))

do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(map_".CLS"), "RecordMap should not be tracked before the compile-time check")
do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should not be tracked before the compile-time check")

new %session, %request, %SourceControl
set %session = ##class(%CSP.Session).%New("dummysession")
set %request = ##class(%CSP.Request).%New()
set %request.Data("pageclass",1) = "EnsPortal.dummy"
do ##class(%Studio.SourceControl.Interface).SourceControlCreate()
do %SourceControl.CheckClassesToAddExplicitly(map_".CLS")

do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(map_".CLS"), "RecordMap should be added to source control by the compile-time fallback check")
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target data class should be added automatically alongside the RecordMap")
}

/// Calling AddRelatedClasses more than once is idempotent: the generated data class is added and
/// remains tracked, and a repeat call succeeds without error.
Method TestAddRelatedClassesIsIdempotent()
{
set map = ..#FIXTUREPACKAGE_".IMap"
set target = ..#FIXTUREPACKAGE_".IRecord"
$$$ThrowOnError(..CreateRecordMap(map, target))

do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should not be tracked before the first call")
$$$ThrowOnError(##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(map_".CLS", 0))
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should be tracked after the first call")

do $$$AssertStatusOK(##class(SourceControl.Git.Util.RecordMap).AddRelatedClasses(map_".CLS", 0))
do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(target_".CLS"), "target should still be tracked after a repeat call, with no error")
}

}
Loading