diff --git a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs index a42eee0a..aab63867 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs @@ -20,10 +20,10 @@ public class DataModelConvertCliCommand : TxcLeafCommand [CliOption( Name = "--input", Aliases = ["-i"], - Description = "Path to the input: a solution project folder (.cdsproj/.csproj with SolutionRootPath), a declarations folder, or a .zip solution file. Defaults to the current directory.", + Description = "Path to an input: a solution project folder (.cdsproj/.csproj with SolutionRootPath), a declarations folder, or a .zip solution file. Can be specified multiple times to merge several sources into one model; earlier inputs win where two declare the same attribute differently. Defaults to the current directory.", Required = false )] - public string? InputPath { get; set; } + public List InputPaths { get; set; } = []; [CliOption( Name = "--target", @@ -43,7 +43,7 @@ public class DataModelConvertCliCommand : TxcLeafCommand protected override Task ExecuteAsync() { - var inputPath = InputPath ?? Directory.GetCurrentDirectory(); + var inputPaths = InputPaths.Count > 0 ? InputPaths : [Directory.GetCurrentDirectory()]; var outputDir = OutputDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), ExportsFolderName); Directory.CreateDirectory(outputDir); @@ -52,7 +52,7 @@ protected override Task ExecuteAsync() var extension = TargetFormat!.ToLower() == "plainsql" ? "sql" : TargetFormat.ToLower(); var outputFilePath = Path.Combine(outputDir, $"solution.{extension}"); - DataModelConverterService.ConvertModel(inputPath, TargetFormat!, outputFilePath); + DataModelConverterService.ConvertModel(inputPaths, TargetFormat!, outputFilePath); OutputFormatter.WriteResult("succeeded", $"Output written to: {outputFilePath}"); return Task.FromResult(ExitSuccess); diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs index 36a1d17c..0a374bc6 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs @@ -18,7 +18,7 @@ namespace TALXIS.CLI.Features.Data.DataModelConverter; public class DataModelConverterService { private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataModelConverterService)); - private static readonly string[] SupportedFormats = ["dbml", "sql", "edmx", "ribbon"]; + private static readonly string[] SupportedFormats = ["dbml", "sql", "plainsql", "edmx", "ribbon"]; /// /// Parses a Power Platform solution from a solution project folder, a declarations @@ -35,29 +35,44 @@ public class DataModelConverterService /// /// public static void ConvertModel(string inputPath, string targetFormat, string outputFilePath) + => ConvertModel([inputPath], targetFormat, outputFilePath); + + /// + /// Converts one or more inputs into a single model. Each input is resolved + /// independently -- a solution project folder, a declarations folder, or a .zip -- and + /// they may be mixed. Earlier inputs take precedence where two declare the same + /// attribute differently. + /// + public static void ConvertModel(List inputPaths, string targetFormat, string outputFilePath) { if (!SupportedFormats.Contains(targetFormat.ToLower())) throw new ArgumentException($"Unsupported target format '{targetFormat}'. Supported formats are: {string.Join(", ", SupportedFormats)}."); - ParsedModel parsedModel; + if (inputPaths is null || inputPaths.Count == 0) + throw new ArgumentException("At least one input path is required."); - if (Directory.Exists(inputPath)) - { - var declarationsPath = ResolveDeclarationsFolder(inputPath); - parsedModel = ParseModelFolder(declarationsPath); - } - else if (File.Exists(inputPath)) - { - using var fileStream = new FileStream(inputPath, FileMode.Open, FileAccess.Read); - using var memoryStream = new MemoryStream(); - fileStream.CopyTo(memoryStream); - parsedModel = ParseModel(Convert.ToBase64String(memoryStream.ToArray())); - } - else + List modules = []; + foreach (var inputPath in inputPaths) { - throw new FileNotFoundException($"Input path '{inputPath}' does not exist."); + if (Directory.Exists(inputPath)) + { + modules.Add(ParseFolderIntoModule(ResolveDeclarationsFolder(inputPath))); + } + else if (File.Exists(inputPath)) + { + using var fileStream = new FileStream(inputPath, FileMode.Open, FileAccess.Read); + using var memoryStream = new MemoryStream(); + fileStream.CopyTo(memoryStream); + modules.Add(ParseZipIntoModule(Convert.ToBase64String(memoryStream.ToArray()))); + } + else + { + throw new FileNotFoundException($"Input path '{inputPath}' does not exist."); + } } + var parsedModel = ParseModules(modules); + var resultString = targetFormat.ToLower() switch { "edmx" => ConvertToEDMX(parsedModel), @@ -255,11 +270,51 @@ public static string ConvertToEDMX(ParsedModel model) } public static ParsedModel ParseModelFolder(string folderPath) + => ParseModelFolders([folderPath]); + + /// + /// Parses several declarations folders into one model, merging attribute-level. + /// A project's model is rarely one solution: the base product ships several modules + /// that each declare part of a shared table, so converting them separately and + /// concatenating the files loses everything but the first declaration of each table. + /// + public static ParsedModel ParseModelFolders(List folderPaths) + => ParseModules([.. folderPaths.Select(ParseFolderIntoModule)]); + + /// + /// Names a module after the folders that own its declarations, so tables can be + /// attributed once several inputs are merged. Several segments are kept because the + /// leaf is almost always "Model" -- one segment would give every input the same name + /// and, with the colour derived from it, the same colour. + /// + private static string ModuleNameFor(string declarationsFolder) { - Module module = new(); + var full = Path.GetFullPath(declarationsFolder) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var segments = full.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Where(x => x.Length > 0) + .ToList(); + + // Drop the trailing "Declarations" (or "CDS") folder; it carries no information. + if (segments.Count > 1 && (segments[^1].Equals("Declarations", StringComparison.OrdinalIgnoreCase) + || segments[^1].Equals("CDS", StringComparison.OrdinalIgnoreCase))) + { + segments.RemoveAt(segments.Count - 1); + } + + return string.Join('/', segments.TakeLast(3)); + } + + private static Module ParseFolderIntoModule(string folderPath) + { + Module module = new() { ModuleName = ModuleNameFor(folderPath) }; // Get files named Entity.xml in subfolders - var entityFiles = Directory.GetFiles(folderPath, "Entity.xml", SearchOption.AllDirectories); + // Ordered: Directory.GetFiles gives no ordering guarantee, so without this the + // table, relationship and enum order in the output varies by filesystem and the + // result cannot be committed or diffed. + var entityFiles = Directory.GetFiles(folderPath, "Entity.xml", SearchOption.AllDirectories) + .OrderBy(f => f, StringComparer.Ordinal).ToArray(); foreach (var file in entityFiles) { @@ -284,7 +339,7 @@ public static ParsedModel ParseModelFolder(string folderPath) // Get files in folder Other/Relationships (directory may not exist in scaffolded solutions) var relationshipsDir = Path.Combine(folderPath, "Other", "Relationships"); var relationshipFiles = Directory.Exists(relationshipsDir) - ? Directory.GetFiles(relationshipsDir, "*.xml", SearchOption.AllDirectories) + ? [.. Directory.GetFiles(relationshipsDir, "*.xml", SearchOption.AllDirectories).OrderBy(f => f, StringComparer.Ordinal)] : Array.Empty(); foreach (var file in relationshipFiles) { @@ -302,7 +357,7 @@ public static ParsedModel ParseModelFolder(string folderPath) // Get files in folder called OptionSets (directory may not exist in scaffolded solutions) var optionsetsDir = Path.Combine(folderPath, "OptionSets"); var optionsetFiles = Directory.Exists(optionsetsDir) - ? Directory.GetFiles(optionsetsDir, "*.xml", SearchOption.AllDirectories) + ? [.. Directory.GetFiles(optionsetsDir, "*.xml", SearchOption.AllDirectories).OrderBy(f => f, StringComparer.Ordinal)] : Array.Empty(); foreach (var file in optionsetFiles) { @@ -317,8 +372,24 @@ public static ParsedModel ParseModelFolder(string folderPath) } } - return ParseModules([module]); + return module; + } + private static Module ParseZipIntoModule(string base64solution) + { + using ZipArchive archive = new(new MemoryStream(Convert.FromBase64String(base64solution))); + + var customizationsxml = archive.Entries.FirstOrDefault(x => x.FullName.Equals("customizations.xml", StringComparison.OrdinalIgnoreCase)); + var solutionxml = archive.Entries.FirstOrDefault(x => x.FullName.Equals("solution.xml", StringComparison.OrdinalIgnoreCase)); + + if (customizationsxml == null || solutionxml == null) + { + throw new FileNotFoundException("The solution archive does not contain the required customizations.xml or solution.xml files."); + } + + return new Module( + XDocument.Load(solutionxml.Open()).Descendants().First(x => x.Name == "UniqueName").Value, + XDocument.Load(customizationsxml.Open())); } public static ParsedModel ParseModel(string? base64solution) @@ -338,19 +409,7 @@ public static ParsedModel ParseModel(List base64solution) foreach (var solution in base64solution) { - using ZipArchive archive = new(new MemoryStream(Convert.FromBase64String(solution))); - - var customizationsxml = archive.Entries.FirstOrDefault(x => x.FullName.Equals("customizations.xml", StringComparison.OrdinalIgnoreCase)); - var solutionxml = archive.Entries.FirstOrDefault(x => x.FullName.Equals("solution.xml", StringComparison.OrdinalIgnoreCase)); - - if (customizationsxml == null || solutionxml == null) - { - throw new FileNotFoundException("The solution archive does not contain the required customizations.xml or solution.xml files."); - } - - Module foundModule = new(XDocument.Load(solutionxml.Open()).Descendants().First(x => x.Name == "UniqueName").Value, XDocument.Load(customizationsxml.Open())); - - modules.Add(foundModule); + modules.Add(ParseZipIntoModule(solution)); } return ParseModules(modules); @@ -362,15 +421,20 @@ public static ParsedModel ParseModules(List modules) List EntityTables = ParseEntities(modules); List EntityOptionSets = ParseOptionSets(modules); - // Remove optionset rows without optionsets defined + // Downgrade optionset rows whose optionset is not resolvable here (declared with no + // options, owned by another module, or platform-owned) rather than dropping the column. var validOptionSetNames = EntityOptionSets.Select(x => x.LocalizedName).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entity in EntityTables) + foreach (var row in EntityTables + .SelectMany(entity => entity.Rows) + .Where(row => + row.RowType is (RowType.Picklist or RowType.Multiselectoptionset or RowType.State or RowType.Status or RowType.Bit) + && !validOptionSetNames.Contains(row.OptionSetName))) { - entity.Rows = [.. entity.Rows - .Where(row => - row.RowType is not (RowType.Picklist or RowType.Multiselectoptionset or RowType.State or RowType.Status or RowType.Bit) - || validOptionSetNames.Contains(row.OptionSetName) - )]; + // Clearing OptionSetName is enough and is all that is needed: it is what + // ToDbDiagramNotation prefers over RowType, so leaving it set would make the + // column reference an Enum that was never emitted. RowType is deliberately + // left alone so each translator keeps its own handling for the kind. + row.OptionSetName = string.Empty; } // Fill in setnames where missing with placeholder logical names @@ -420,6 +484,21 @@ public static List ParseRelationships(List modules, List ParseRelationships(List modules, List x.RowType == RowType.Primarykey), connectionTable, - connectionTable.Rows.FirstOrDefault(x => x.Name == firstEntityTable.LogicalName + "id")); + connectionTable.Rows.FirstOrDefault(x => x.Name == firstRowName)); - var secondToMid = new Relationship(relationship.Attribute("Name").Value, + var secondToMid = new Relationship(secondRelationshipName, "ManyToOne", secondEntityTable, secondEntityTable.Rows.FirstOrDefault(x => x.RowType == RowType.Primarykey), connectionTable, - connectionTable.Rows.FirstOrDefault(x => x.Name == secondEntityTable.LogicalName + "id")); + connectionTable.Rows.FirstOrDefault(x => x.Name == secondRowName)); EntityRelationships.Add(firstToMid); EntityRelationships.Add(secondToMid); @@ -489,7 +579,7 @@ public static List ParseRelationships(List modules, List x.RowType == RowType.Primarykey)); - if (EntityRelationships.FirstOrDefault(x => x.LeftSideTable == entityRelationship.LeftSideTable && x.RighSideTable == entityRelationship.RighSideTable) == default) + if (EntityRelationships.FirstOrDefault(x => x.LeftSideTable == entityRelationship.LeftSideTable && x.LeftSideRow == entityRelationship.LeftSideRow && x.RighSideTable == entityRelationship.RighSideTable) == default) { EntityRelationships.Add(entityRelationship); } diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs index 4a8ea5d7..c69992ab 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs @@ -9,20 +9,13 @@ namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class Module { - public Module() - { - var random = new Random(); - Colorhex = string.Format("#{0:X6}", random.Next(0x1000000)); - } + public Module() { } public Module(string module, XDocument xml) { ModuleName = module; XmlDoc = xml; - var random = new Random(); - Colorhex = string.Format("#{0:X6}", random.Next(0x1000000)); - entities = XmlDoc.Descendants().Where(x => x.Name == "Entity").ToList(); relationships = XmlDoc.Descendants().Where(x => x.Name == "EntityRelationship").ToList(); optionsets = XmlDoc.Descendants().Where(x => x.Name == "optionset").ToList(); @@ -35,5 +28,25 @@ public Module(string module, XDocument xml) public List relationships = []; public List optionsets = []; - public string Colorhex { get; } + /// Computed, not assigned in the constructor: an object initializer sets + /// ModuleName after the constructor body runs, which would colour every module + /// from an empty name. + public string Colorhex => ColourFor(ModuleName); + + /// + /// Derives the module colour from its name so the same input always converts to the + /// same bytes. A random colour made every conversion a spurious diff, which meant a + /// generated diagram could not be committed or compared across a model change. + /// FNV-1a rather than string.GetHashCode, which is randomised per process on .NET Core. + /// + private static string ColourFor(string moduleName) + { + uint hash = 2166136261; + foreach (var c in moduleName ?? string.Empty) + { + hash ^= c; + hash *= 16777619; + } + return string.Format("#{0:X6}", hash & 0xFFFFFF); + } } diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs index 5ccd5960..7241b512 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs @@ -1,4 +1,6 @@ -using DocumentFormat.OpenXml.Vml.Office; +using TALXIS.CLI.Logging; +using Microsoft.Extensions.Logging; +using DocumentFormat.OpenXml.Vml.Office; using System.Text.Json.Serialization; using System; using System.Collections.Generic; @@ -20,6 +22,8 @@ public enum TableType public class Table { + private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(Table)); + public Table() { } public Table(XElement element) @@ -58,8 +62,28 @@ public void ParseMultipleRowsFromXml(List xElements) foreach (var element in xElements) { var row = TableRow.ParseXElement(element); - if (row != null) + if (row == null) + continue; + + var existing = Rows.FirstOrDefault(x => string.Equals(x.Name, row.Name, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { Rows.Add(row); + } + else if (existing.RowType != row.RowType) + { + // Several modules extending one shared table is the normal case for a + // layered product, so a divergent declaration warns rather than aborting. + // First input wins, which makes the result deterministic in input order. + _logger.LogWarning( + "Attribute {Table}.{Attribute} is declared as {ExistingType} in one input and {NewType} in another; keeping the first.", + LogicalName, row.Name, existing.RowType, row.RowType); + } + else if (row.MaxLenght > existing.MaxLenght) + { + // Widen, never narrow: a consumer breaks on too little room, not too much. + existing.MaxLenght = row.MaxLenght; + } } } diff --git a/tests/TALXIS.CLI.Tests/Data/DataModelConverter/DataModelConverterServiceTests.cs b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/DataModelConverterServiceTests.cs new file mode 100644 index 00000000..c764cc66 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/DataModelConverterServiceTests.cs @@ -0,0 +1,239 @@ +using System.IO; +using System.Linq; +using System.Reflection; +using DotMake.CommandLine; +using TALXIS.CLI.Features.Data; +using System.Xml.Linq; +using TALXIS.CLI.Features.Data.DataModelConverter; +using Model = TALXIS.CLI.Features.Data.DataModelConverter.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Data.DataModelConverter; + +/// +/// Regression tests for four defects that made the converter silently lose or corrupt +/// model content. Each was found by diffing converter output against the source +/// declarations of real solutions; each test fails against the unfixed converter. +/// +public class DataModelConverterServiceTests +{ + private static XElement Entity(string logicalName, params string[] attributes) + { + var attrXml = string.Join("", attributes); + return XElement.Parse($""" + + {logicalName} + + + + primarykey + {attrXml} + + + + + """); + } + + private static string Attr(string name, string type) => + $"""{type}"""; + + private static string Lookup(string name) => Attr(name, "lookup"); + + private static XElement OneToMany(string name, string child, string childAttr, string parent) => + XElement.Parse($""" + + OneToMany + {child} + {parent} + {childAttr} + + """); + + private static Model.Module ModuleWith(XElement[] entities, XElement[]? relationships = null) + { + var module = new Model.Module(); + module.entities.AddRange(entities); + if (relationships != null) module.relationships.AddRange(relationships); + return module; + } + + // ---- Defect 1: relationships deduped on the table pair, not the column ------------- + + [Fact] + public void TwoLookupsBetweenSameTablePair_BothProduceRelationships() + { + var module = ModuleWith( + [Entity("account"), Entity("contoso_project", Lookup("contoso_ownerid"), Lookup("contoso_billtoid"))], + [ + OneToMany("rel_owner", "contoso_project", "contoso_ownerid", "account"), + OneToMany("rel_billto", "contoso_project", "contoso_billtoid", "account"), + ]); + + var model = DataModelConverterService.ParseModules([module]); + + var toAccount = model.relationships + .Where(r => r.RighSideTable?.LogicalName == "account") + .Select(r => r.LeftSideRow?.Name) + .ToList(); + + Assert.Equal(2, toAccount.Count); + Assert.Contains("contoso_ownerid", toAccount); + Assert.Contains("contoso_billtoid", toAccount); + } + + // ---- Defect 3: a column vanishes when its option set will not resolve -------------- + + [Fact] + public void PicklistWithUnresolvableOptionSet_KeepsColumnInsteadOfDroppingIt() + { + var picklist = """ + + picklist + contoso_never_declared_anywhere + + """; + var module = ModuleWith([Entity("contoso_thing", picklist)]); + + var table = DataModelConverterService.ParseModules([module]) + .tables.Single(t => t.LogicalName == "contoso_thing"); + + var row = table.Rows.SingleOrDefault(r => r.Name == "contoso_statuscode"); + Assert.NotNull(row); + // Cleared so the column cannot reference an Enum that was never emitted; + // RowType is deliberately left alone so each translator keeps its own handling. + Assert.True(string.IsNullOrEmpty(row!.OptionSetName)); + } + + // ---- Defect 4: output was not reproducible — colours came from new Random() -------- + + [Fact] + public void ModuleColour_IsDerivedFromName_SoConversionIsReproducible() + { + var a = new Model.Module("Areas/Service/Project/Model", new XDocument(new XElement("root"))); + var b = new Model.Module("Areas/Service/Project/Model", new XDocument(new XElement("root"))); + var other = new Model.Module("Areas/Environment/Start/Model", new XDocument(new XElement("root"))); + + Assert.Equal(a.Colorhex, b.Colorhex); + Assert.NotEqual(a.Colorhex, other.Colorhex); + Assert.Matches("^#[0-9A-F]{6}$", a.Colorhex); + } + + // ---- Defect 5: a self-referencing N:N emitted duplicate columns and refs ----------- + + [Fact] + public void SelfReferencingManyToMany_ProducesTwoDistinctIntersectColumns() + { + var manyToMany = XElement.Parse(""" + + ManyToMany + contoso_thing + contoso_thing + contoso_thing_thing + + """); + var module = ModuleWith([Entity("contoso_thing")], [manyToMany]); + + var model = DataModelConverterService.ParseModules([module]); + var intersect = model.tables.Single(t => t.LogicalName == "contoso_thing_thing"); + + var names = intersect.Rows.Select(r => r.Name).ToList(); + Assert.Equal(names.Count, names.Distinct().Count()); + + // Two legs, each anchored on its own column — one shared column produced a + // duplicate endpoint pair, which a DBML parser rejects outright. + var legs = model.relationships.Where(r => r.RighSideTable?.LogicalName == "contoso_thing_thing").ToList(); + Assert.Equal(2, legs.Count); + Assert.Equal(2, legs.Select(l => l.RighSideRow?.Name).Distinct().Count()); + + // The legs also need distinct relationship names: EDMX renders the intersect side + // as NavigationProperty Name="{relationship.Name}", with a matching Partner and + // NavigationPropertyBinding Path, so sharing one name emits each of them twice. + Assert.Equal(2, legs.Select(l => l.Name).Distinct().Count()); + } + + [Fact] + public void SelfReferencingManyToMany_RendersDistinctNavigationPropertiesOnTheIntersect() + { + var manyToMany = XElement.Parse(""" + + ManyToMany + contoso_thing + contoso_thing + contoso_thing_thing + + """); + var module = ModuleWith([Entity("contoso_thing")], [manyToMany]); + + var model = DataModelConverterService.ParseModules([module]); + var edmx = DataModelConverterService.ConvertToEDMX(model); + + // The intersect's own EntityType carries one navigation property per leg. + var intersect = System.Text.RegularExpressions.Regex.Match( + edmx, "", + System.Text.RegularExpressions.RegexOptions.Singleline).Value; + var navNames = System.Text.RegularExpressions.Regex + .Matches(intersect, " m.Groups[1].Value).ToList(); + + Assert.Equal(2, navNames.Count); + Assert.Equal(2, navNames.Distinct().Count()); + } + + // ---- Every relationship endpoint must be non-null: the translators dereference ----- + // ---- LeftSideRow/RighSideRow without a null check. ------------------------------- + + [Fact] + public void EveryEmittedRelationship_HasBothEndpointsResolved() + { + var module = ModuleWith( + [Entity("account"), Entity("contoso_project", Lookup("contoso_ownerid"))], + [OneToMany("rel_owner", "contoso_project", "contoso_ownerid", "account")]); + + var model = DataModelConverterService.ParseModules([module]); + + Assert.All(model.relationships, r => + { + Assert.NotNull(r.LeftSideTable); + Assert.NotNull(r.LeftSideRow); + Assert.NotNull(r.RighSideTable); + Assert.NotNull(r.RighSideRow); + }); + } + + // ---- Every format the CLI advertises must actually convert ------------------------ + // plainsql was listed in the option's AllowedValues and fully implemented in the + // format switch, but missing from the service's SupportedFormats guard -- so it was + // rejected on every invocation, and the error message listed that same guard as truth. + + [Fact] + public void EveryAdvertisedTargetFormat_IsAcceptedByTheService() + { + var advertised = typeof(DataModelConvertCliCommand) + .GetProperty(nameof(DataModelConvertCliCommand.TargetFormat))! + .GetCustomAttribute()! + .AllowedValues! + .Cast() + .ToList(); + + Assert.NotEmpty(advertised); + + var dir = Path.Combine(Path.GetTempPath(), "txc-fmt-" + Path.GetRandomFileName()); + var entityDir = Path.Combine(dir, "Entities", "contoso_thing"); + Directory.CreateDirectory(entityDir); + File.WriteAllText(Path.Combine(entityDir, "Entity.xml"), Entity("contoso_thing").ToString()); + try + { + foreach (var format in advertised) + { + var outFile = Path.Combine(dir, "out." + format); + var ex = Record.Exception(() => DataModelConverterService.ConvertModel(dir, format, outFile)); + Assert.True(ex is null, $"--target {format} is advertised but failed: {ex?.Message}"); + } + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Data/DataModelConverter/MultipleInputMergeTests.cs b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/MultipleInputMergeTests.cs new file mode 100644 index 00000000..c1fe45ac --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/MultipleInputMergeTests.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using TALXIS.CLI.Features.Data.DataModelConverter; +using Model = TALXIS.CLI.Features.Data.DataModelConverter.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Data.DataModelConverter; + +/// +/// Merging several declaration folders into one model. A delivery project's data model is +/// spread across the modules a product ships plus the project's own layer, and several of +/// them declare part of the same table — so converting each separately and concatenating +/// the output keeps only the first declaration of each table. +/// +public class MultipleInputMergeTests +{ + private static XElement Entity(string logicalName, params string[] attributes) => + XElement.Parse($""" + + {logicalName} + + + + primarykey + {string.Join("", attributes)} + + + + + """); + + private static string Attr(string name, string type, int? maxLength = null) => + $"""{type}{(maxLength is null ? "" : $"{maxLength}")}"""; + + private static Model.Module ModuleOf(string name, params XElement[] entities) + { + var module = new Model.Module { ModuleName = name }; + module.entities.AddRange(entities); + return module; + } + + private static Model.Table TableIn(Model.ParsedModel model, string logicalName) => + model.tables.Single(t => t.LogicalName == logicalName); + + [Fact] + public void TwoModulesDeclaringOneTable_MergeIntoASingleTableWithBothColumns() + { + var a = ModuleOf("base", Entity("contoso_thing", Attr("contoso_fromBase", "nvarchar", 50))); + var b = ModuleOf("layer", Entity("contoso_thing", Attr("contoso_fromLayer", "nvarchar", 50))); + + var model = DataModelConverterService.ParseModules([a, b]); + + Assert.Single(model.tables, t => t.LogicalName == "contoso_thing"); + var names = TableIn(model, "contoso_thing").Rows.Select(r => r.Name).ToList(); + Assert.Contains("contoso_frombase", names.Select(n => n.ToLowerInvariant())); + Assert.Contains("contoso_fromlayer", names.Select(n => n.ToLowerInvariant())); + } + + [Fact] + public void SameAttributeDeclaredInBothModules_ProducesOneColumnNotTwo() + { + var attr = Attr("contoso_shared", "nvarchar", 50); + var model = DataModelConverterService.ParseModules( + [ModuleOf("base", Entity("contoso_thing", attr)), ModuleOf("layer", Entity("contoso_thing", attr))]); + + var rows = TableIn(model, "contoso_thing").Rows + .Where(r => string.Equals(r.Name, "contoso_shared", System.StringComparison.OrdinalIgnoreCase)); + Assert.Single(rows); + } + + [Fact] + public void ConflictingTypeForOneAttribute_KeepsTheFirstInputsDeclaration() + { + var model = DataModelConverterService.ParseModules( + [ + ModuleOf("first", Entity("contoso_thing", Attr("contoso_field", "nvarchar", 50))), + ModuleOf("second", Entity("contoso_thing", Attr("contoso_field", "int"))), + ]); + + var row = TableIn(model, "contoso_thing").Rows + .Single(r => string.Equals(r.Name, "contoso_field", System.StringComparison.OrdinalIgnoreCase)); + Assert.Equal(Model.RowType.Nvarchar, row.RowType); + } + + [Theory] + [InlineData(50, 200, 200)] + [InlineData(200, 50, 200)] + public void DifferingTextLengths_WidenNeverNarrow_RegardlessOfInputOrder(int first, int second, int expected) + { + var model = DataModelConverterService.ParseModules( + [ + ModuleOf("first", Entity("contoso_thing", Attr("contoso_text", "nvarchar", first))), + ModuleOf("second", Entity("contoso_thing", Attr("contoso_text", "nvarchar", second))), + ]); + + var row = TableIn(model, "contoso_thing").Rows + .Single(r => string.Equals(r.Name, "contoso_text", System.StringComparison.OrdinalIgnoreCase)); + Assert.Equal(expected, row.MaxLenght); + } + + [Fact] + public void ModulesAreColouredApart_SoAMergedDiagramShowsWhereEachTableCameFrom() + { + var a = new Model.Module { ModuleName = "src/Modules.Core/Model" }; + var b = new Model.Module { ModuleName = "Areas/Service/Project/Model" }; + + // Assigned through an object initializer, which runs after the constructor body — + // a colour computed in the constructor would be identical for both. + Assert.NotEqual(a.Colorhex, b.Colorhex); + } + + [Fact] + public void OneFolder_ThroughTheListEntryPoint_MatchesTheSingleFolderEntryPoint() + { + var dir = Path.Combine(Path.GetTempPath(), "txc-merge-" + Path.GetRandomFileName()); + var entityDir = Path.Combine(dir, "Entities", "contoso_thing"); + Directory.CreateDirectory(entityDir); + File.WriteAllText(Path.Combine(entityDir, "Entity.xml"), + Entity("contoso_thing", Attr("contoso_field", "nvarchar", 50)).ToString()); + try + { + var single = DataModelConverterService.ParseModelFolder(dir); + var viaList = DataModelConverterService.ParseModelFolders([dir]); + + Assert.Equal(single.tables.Count, viaList.tables.Count); + Assert.Equal(single.relationships.Count, viaList.relationships.Count); + Assert.Equal( + single.tables.Select(t => t.LogicalName).OrderBy(x => x), + viaList.tables.Select(t => t.LogicalName).OrderBy(x => x)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void TwoFoldersOnDisk_MergeAttributesAcrossTheFolderBoundary() + { + var root = Path.Combine(Path.GetTempPath(), "txc-merge-" + Path.GetRandomFileName()); + var folders = new List(); + foreach (var (name, attr) in new[] { ("base", "contoso_a"), ("layer", "contoso_b") }) + { + var dir = Path.Combine(root, name, "Declarations"); + Directory.CreateDirectory(Path.Combine(dir, "Entities", "contoso_thing")); + File.WriteAllText(Path.Combine(dir, "Entities", "contoso_thing", "Entity.xml"), + Entity("contoso_thing", Attr(attr, "nvarchar", 50)).ToString()); + folders.Add(dir); + } + try + { + var model = DataModelConverterService.ParseModelFolders(folders); + var names = TableIn(model, "contoso_thing").Rows + .Select(r => r.Name.ToLowerInvariant()).ToList(); + + Assert.Contains("contoso_a", names); + Assert.Contains("contoso_b", names); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +}