");
rebound.Add($"{oldTable} -> {newTable}");
- // The datasource element that carried the table, and every control pointing at it.
+ // The datasource element that carried the table, and everything pointing at it.
var dsName = $"{Regex.Escape(oldTable)}";
if (Regex.IsMatch(xml, dsName))
{
xml = Regex.Replace(xml, dsName, $"{newTable}");
- xml = Regex.Replace(xml, $"{Regex.Escape(oldTable)}", $"{newTable}");
+ xml = RenameDataSourceReferences(xml, oldTable, newTable);
renamed.Add($"{oldTable} -> {newTable}");
}
@@ -159,6 +159,48 @@ private static (List Rebound, List Renamed) RebindTables(
return (rebound, renamed);
}
+ ///
+ /// Elements whose text is the name of a datasource, so a renamed datasource has to be
+ /// followed into all of them.
+ ///
+ ///
+ /// Grounded on a live installation rather than guessed: across 300 shipped
+ /// ApplicationSuite\Foundation\AxForm forms these are the only elements carrying a
+ /// datasource name as their value. The rest of the *DataSource* family is either a
+ /// container (DataSources, DataSourceLinks, ReferencedDataSources) or
+ /// holds something else entirely (DataSourceChangeGroupMode is an enum,
+ /// DataSourceRelation names a relation). Datasource names reached through a nested
+ /// <Name> — root links, referenced and derived datasources — are already covered
+ /// by the <Name> rewrite above.
+ ///
+ private static readonly string[] DataSourceRefElements =
+ ["DataSource", "TitleDataSource", "WorkflowDataSource", "PresenceDataSource", "JoinSource"];
+
+ ///
+ /// Repoint every reference to a renamed datasource, whatever attributes the tag carries.
+ ///
+ ///
+ /// The attribute tolerance is the whole point. A <Design>'s own
+ /// DataSource/TitleDataSource are first-level children written in the empty
+ /// namespace, so they appear as <DataSource xmlns="">, while the control-level
+ /// ones nested under <Controls xmlns=""> inherit it and appear bare. Matching only
+ /// the bare form rebinds the controls and leaves the design pointing at a datasource the
+ /// clone no longer has.
+ ///
+ /// The alternation cannot bleed into a longer tag: the group must be followed immediately by
+ /// whitespace or >, so DataSource never matches the start of
+ /// DataSourceLinks. The closing tag is a backreference, so the two always agree.
+ ///
+ ///
+ private static string RenameDataSourceReferences(string xml, string oldName, string newName)
+ {
+ var tags = string.Join('|', DataSourceRefElements);
+ return Regex.Replace(
+ xml,
+ $@"<({tags})((?:\s[^>]*)?)>{Regex.Escape(oldName)}\1>",
+ m => $"<{m.Groups[1].Value}{m.Groups[2].Value}>{newName}{m.Groups[1].Value}>");
+ }
+
///
/// The clone has to still be a parseable form carrying its new name.
///
diff --git a/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs b/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs
index c7a6d78..1e4707b 100644
--- a/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs
+++ b/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs
@@ -33,9 +33,10 @@ public void Lists_every_authored_case()
Assert.Equal(0, exit);
Assert.Contains("\"ok\":true", stdout);
- Assert.Contains("\"count\":51", stdout);
+ Assert.Contains("\"count\":52", stdout);
Assert.Contains("L0-edt-basic", stdout);
Assert.Contains("L2-coc-extension", stdout);
Assert.Contains("L1-form-workspace", stdout);
+ Assert.Contains("L2-form-clone-basic", stdout);
}
}
diff --git a/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs b/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs
index e71c1a9..8e38630 100644
--- a/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs
+++ b/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs
@@ -14,7 +14,7 @@ public void Loads_the_real_authored_catalog_with_no_errors()
var (cases, errors) = EvalCaseCatalog.LoadAll(EvalPaths.CasesDir(RepoRoot));
Assert.Empty(errors);
- Assert.Equal(51, cases.Count);
+ Assert.Equal(52, cases.Count);
Assert.Contains(cases, c => c.Id == "L0-edt-basic");
Assert.Contains(cases, c => c.Id == "L0-enum-basic");
Assert.Contains(cases, c => c.Id == "L1-table-basic");
diff --git a/tests/D365FO.Core.Tests/FormClonerTests.cs b/tests/D365FO.Core.Tests/FormClonerTests.cs
index 5ae6dbf..5f8ceab 100644
--- a/tests/D365FO.Core.Tests/FormClonerTests.cs
+++ b/tests/D365FO.Core.Tests/FormClonerTests.cs
@@ -11,7 +11,10 @@ namespace D365FO.Core.Tests;
///
/// The fixture mirrors the shape of a real shipped form: the root name, the X++ class
/// declaration, a datasource entry under <SourceCode> (where override methods live),
-/// the design datasource, and a control pointing at it by name. Verified against
+/// the design datasource, the design's own DataSource/TitleDataSource properties
+/// (first-level children of <Design>, so they carry an explicit xmlns="" where
+/// the control-level ones inherit it), and a control pointing at the datasource by name. Verified
+/// against
/// ApplicationSuite\Foundation\AxForm\CustGroup.xml on a live installation — a 16 KB form
/// where the clone differs from the source on exactly the intended lines and is byte-identical
/// everywhere else.
@@ -41,8 +44,15 @@ public class FormClonerTests
CustGroup
CustGroup
+
+ CustTrans
+
CustTrans
+ CustGroup
+
+ CustGroup
+ CustGroupGrid_CustGroupIdCustGroupId
@@ -89,6 +99,61 @@ public void A_rebind_moves_the_table_the_datasource_and_every_control_that_names
Assert.Single(result.RenamedDataSources);
}
+ [Fact]
+ public void A_rebind_follows_the_datasource_into_the_design_properties_that_name_it()
+ {
+ // The design's own DataSource/TitleDataSource are first-level children of , so
+ // they are written as . A pattern anchored on the bare ""
+ // misses them, and the clone then names a datasource it no longer has — a form that
+ // compiles and breaks at runtime. Grounded on a live installation: of 300 shipped
+ // ApplicationSuite forms, 128 and 78 nodes carry attributes.
+ var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable"));
+
+ Assert.Contains("ConVehicleGroupTable", result.Xml);
+ Assert.Contains("ConVehicleGroupTable", result.Xml);
+ Assert.DoesNotContain("CustGroup", result.Xml);
+ Assert.DoesNotContain("CustGroup", result.Xml);
+ }
+
+ [Fact]
+ public void A_rebind_follows_the_datasource_into_the_join_that_names_it()
+ {
+ // A joined datasource names its parent by datasource name, so renaming the parent has to
+ // reach the join too — otherwise the child joins to something that no longer exists.
+ var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable"));
+
+ Assert.Contains("ConVehicleGroupTable", result.Xml);
+ }
+
+ [Fact]
+ public void A_tag_that_merely_starts_with_a_matched_element_name_is_not_rewritten()
+ {
+ // The alternation must not let "DataSource" bleed into "DataSourceLinks" and friends.
+ var withLinks = Source.Replace(
+ "CustGroup",
+ "CustGroupCustGroup");
+
+ var result = FormCloner.Clone(withLinks, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable"));
+
+ Assert.Contains("CustGroup", result.Xml);
+ }
+
+ [Fact]
+ public void A_rebind_leaves_no_control_or_design_node_naming_the_old_datasource()
+ {
+ // The whole-document guarantee behind the case above: after a rebind that renamed the
+ // datasource, nothing anywhere still refers to the old name.
+ var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable"));
+
+ var stillNamingOld = XDocument.Parse(result.Xml)
+ .Descendants()
+ .Where(e => e.Value.Trim() == "CustGroup" && !e.HasElements)
+ .Select(e => e.Name.LocalName)
+ .ToList();
+
+ Assert.Empty(stillNamingOld);
+ }
+
[Fact]
public void The_form_is_renamed_before_the_rebind_so_the_root_never_takes_the_table_name()
{