diff --git a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs index a42eee0a..baa08458 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs @@ -20,10 +20,24 @@ 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 = "--root", + Description = "Path to a repository root; every declarations folder beneath it becomes an input. Can be specified multiple times. Use this rather than listing folders when a project's model spans many modules, and pass the product repository as a second root when the base model lives there.", + Required = false + )] + public List Roots { get; set; } = []; + + [CliOption( + Name = "--app", + Description = "Unique name of a model-driven app. Narrows the output to the tables that app is built on, instead of everything the inputs declare. App modules are searched for under --root, or under the inputs when no root is given.", + Required = false + )] + public string? AppUniqueName { get; set; } [CliOption( Name = "--target", @@ -43,7 +57,33 @@ public class DataModelConvertCliCommand : TxcLeafCommand protected override Task ExecuteAsync() { - var inputPath = InputPath ?? Directory.GetCurrentDirectory(); + var inputPaths = new List(InputPaths); + + foreach (var root in Roots) + { + var discovered = DataModelConverterService.DiscoverDeclarationFolders(root); + if (discovered.Count == 0) + { + Logger.LogWarning("No declarations were found under root {Root}.", root); + } + inputPaths.AddRange(discovered); + } + + // Scoping to an app needs to reach the module that declares it, which is not the + // module that declares the entities -- so when only an app is named, search from + // the enclosing repository rather than the working directory alone. + var appSearchRoots = new List(Roots); + if (!string.IsNullOrWhiteSpace(AppUniqueName) && appSearchRoots.Count == 0) + { + var enclosing = FindEnclosingRepositoryRoot(Directory.GetCurrentDirectory()); + appSearchRoots.Add(enclosing); + Logger.LogInformation("Searching for app modules under {Root}.", enclosing); + } + + if (inputPaths.Count == 0) + { + inputPaths.Add(Directory.GetCurrentDirectory()); + } var outputDir = OutputDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), ExportsFolderName); Directory.CreateDirectory(outputDir); @@ -52,12 +92,30 @@ 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, AppUniqueName, appSearchRoots); OutputFormatter.WriteResult("succeeded", $"Output written to: {outputFilePath}"); return Task.FromResult(ExitSuccess); } + /// + /// Walks up for the repository that encloses a directory, so an app can be found + /// without the caller naming a root. Falls back to the directory itself. + /// + private static string FindEnclosingRepositoryRoot(string startPath) + { + var dir = new DirectoryInfo(startPath); + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || dir.GetFiles("*.sln").Length > 0) + { + return dir.FullName; + } + dir = dir.Parent; + } + return startPath; + } + /// /// Ensures the exports folder is listed in the nearest .gitignore, /// adding an entry if it is not already present. diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeFilter.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeFilter.cs new file mode 100644 index 00000000..c22c9b08 --- /dev/null +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeFilter.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Data.DataModelConverter.AppScope; + +/// Narrows a parsed model to the tables an app is built on. +public static class AppScopeFilter +{ + private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AppScopeFilter)); + + /// + /// Drops tables the app does not declare. Runs before relationships are built, so a + /// dropped table cannot come back as a synthesised stub for a relationship that + /// pointed at it. + /// + public static void ApplyTableScope(List tables, ResolvedAppScope scope) + { + var removed = tables.RemoveAll(t => + t.Type == TableType.InSolution && !scope.TableLogicalNames.Contains(t.LogicalName)); + + var missing = scope.TableLogicalNames + .Where(name => !tables.Any(t => string.Equals(t.LogicalName, name, System.StringComparison.OrdinalIgnoreCase))) + .OrderBy(x => x) + .ToList(); + + _logger.LogInformation( + "Scoped to app {App}: kept {Kept} table(s), dropped {Dropped} not declared by it.", + scope.UniqueName, tables.Count, removed); + + if (missing.Count > 0) + { + // The app names them but no input declares them — usually a module that was + // not passed in, which would otherwise show up only as an oddly small diagram. + _logger.LogWarning( + "App {App} references {Count} table(s) that none of the given inputs declare: {Tables}.", + scope.UniqueName, missing.Count, string.Join(", ", missing)); + } + } +} diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeResolver.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeResolver.cs new file mode 100644 index 00000000..e923bcd1 --- /dev/null +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/AppScope/AppScopeResolver.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Data.DataModelConverter.AppScope; + +/// The tables a model-driven app is built on, resolved from source. +public class ResolvedAppScope +{ + public string UniqueName { get; init; } = string.Empty; + + /// Compared case-insensitively: an app component's schemaName casing is not + /// guaranteed to match the casing of the entity's own declaration. + public HashSet TableLogicalNames { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// Every file that contributed, for reporting which sources were read. + public List SourceFiles { get; } = []; +} + +/// +/// Resolves which tables an app declares, from the app module files on disk. No Dataverse +/// connection: the app's component list is in source, and so is everything it names. +/// +public static class AppScopeResolver +{ + private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AppScopeResolver)); + + private const string AppModulesFolder = "AppModules"; + private const string SiteMapsFolder = "AppModuleSiteMaps"; + + /// The component type that carries a table's name. Views, forms, charts and + /// workflows reference their owning table only by id, so they cannot contribute one. + private const string EntityComponentType = "1"; + + public static ResolvedAppScope Resolve(IEnumerable searchRoots, string appUniqueName) + { + var byName = DiscoverAppModules(searchRoots); + + if (!byName.TryGetValue(appUniqueName, out var files) || files.Count == 0) + { + var known = byName.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + throw new InvalidOperationException( + $"No app module named '{appUniqueName}' was found under the given inputs. " + + (known.Count == 0 + ? "No app modules were found at all — check that a repository root containing them was passed." + : $"Apps found: {string.Join(", ", known)}.")); + } + + var scope = new ResolvedAppScope { UniqueName = appUniqueName }; + + // One logical app can be declared across several files: a base declaration plus + // fragments contributed by other areas, whose components carry solutionaction="Added". + // The app's real component set is the union of all of them. + foreach (var file in files) + { + scope.SourceFiles.Add(file); + var doc = Load(file); + if (doc?.Root == null) continue; + + foreach (var component in doc.Root.Descendants("AppModuleComponent")) + { + if (component.Attribute("type")?.Value != EntityComponentType) continue; + var schemaName = component.Attribute("schemaName")?.Value; + if (!string.IsNullOrWhiteSpace(schemaName)) scope.TableLogicalNames.Add(schemaName); + } + } + + foreach (var table in ResolveSiteMapTables(searchRoots, appUniqueName)) + { + scope.TableLogicalNames.Add(table); + } + + _logger.LogInformation( + "App {App} resolves to {Count} tables, from {Files} declaration file(s).", + appUniqueName, scope.TableLogicalNames.Count, scope.SourceFiles.Count); + + return scope; + } + + /// + /// Every app module found, keyed by the UniqueName inside the file. The folder name is + /// only a locator — its casing can differ from the declared name, which matters on a + /// case-sensitive filesystem. + /// + public static Dictionary> DiscoverAppModules(IEnumerable searchRoots) + { + var byName = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var file in FilesUnder(searchRoots, AppModulesFolder, "AppModule*.xml")) + { + var uniqueName = Load(file)?.Root?.Element("UniqueName")?.Value; + if (string.IsNullOrWhiteSpace(uniqueName)) continue; + + if (!byName.TryGetValue(uniqueName, out var list)) + { + byName[uniqueName] = list = []; + } + list.Add(file); + } + + return byName; + } + + /// + /// Tables a sitemap surfaces. They appear either as an Entity attribute or as an etn + /// parameter inside a Url, and both forms occur in the same file. + /// + private static IEnumerable ResolveSiteMapTables(IEnumerable searchRoots, string appUniqueName) + { + foreach (var file in FilesUnder(searchRoots, SiteMapsFolder, "AppModuleSiteMap*.xml")) + { + var doc = Load(file); + if (doc?.Root == null) continue; + + var owner = doc.Root.Element("SiteMapUniqueName")?.Value; + if (!string.Equals(owner, appUniqueName, StringComparison.OrdinalIgnoreCase)) continue; + + foreach (var element in doc.Root.Descendants()) + { + var entity = element.Attribute("Entity")?.Value; + if (!string.IsNullOrWhiteSpace(entity)) yield return entity; + + var url = element.Attribute("Url")?.Value; + if (string.IsNullOrWhiteSpace(url)) continue; + + foreach (var part in url.Split('&', '?')) + { + if (part.StartsWith("etn=", StringComparison.OrdinalIgnoreCase) && part.Length > 4) + { + yield return part[4..]; + } + } + } + } + } + + /// + /// Anchors on the component folder rather than on "Declarations": some modules keep + /// their declarations under "CDS" instead, and a glob anchored on either name misses + /// the other. + /// + private static IEnumerable FilesUnder(IEnumerable searchRoots, string folderName, string pattern) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var root in searchRoots.Where(Directory.Exists)) + { + foreach (var folder in Directory.EnumerateDirectories(root, folderName, SearchOption.AllDirectories)) + { + foreach (var file in Directory.EnumerateFiles(folder, pattern, SearchOption.AllDirectories)) + { + // Managed and unmanaged copies sit side by side with identical content. + if (seen.Add(Path.GetFullPath(file))) yield return file; + } + } + } + } + + private static XDocument? Load(string file) + { + try + { + return XDocument.Load(file); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not read app module {File}; skipping it.", file); + return null; + } + } +} diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs index 36a1d17c..8f3fbad9 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs @@ -13,12 +13,14 @@ using TALXIS.CLI.Features.Data.DataModelConverter.Translators; using TALXIS.CLI.Logging; +using TALXIS.CLI.Features.Data.DataModelConverter.AppScope; + 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 +37,59 @@ 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) + => ConvertModel(inputPaths, targetFormat, outputFilePath, null, null); + + /// + /// Converts one or more inputs into a single model, optionally narrowed to the tables a + /// model-driven app is built on. is where app modules + /// are looked for; apps and entity schema live in different modules, so this is usually + /// a repository root rather than a declarations folder. + /// + public static void ConvertModel(List inputPaths, string targetFormat, string outputFilePath, string? appUniqueName, List? appSearchRoots) { 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)) + List modules = []; + foreach (var inputPath in inputPaths) { - using var fileStream = new FileStream(inputPath, FileMode.Open, FileAccess.Read); - using var memoryStream = new MemoryStream(); - fileStream.CopyTo(memoryStream); - parsedModel = ParseModel(Convert.ToBase64String(memoryStream.ToArray())); + 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."); + } } - else + + ResolvedAppScope? appScope = null; + if (!string.IsNullOrWhiteSpace(appUniqueName)) { - throw new FileNotFoundException($"Input path '{inputPath}' does not exist."); + appScope = AppScopeResolver.Resolve(appSearchRoots is { Count: > 0 } ? appSearchRoots : inputPaths, appUniqueName); } + var parsedModel = ParseModules(modules, appScope); + var resultString = targetFormat.ToLower() switch { "edmx" => ConvertToEDMX(parsedModel), @@ -255,11 +287,68 @@ 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)); + } + + /// + /// Finds every declarations folder beneath a root, by looking for the entity + /// declarations themselves rather than for a folder name -- modules keep them under + /// "Declarations" or, in older ones, "CDS". + /// + public static List DiscoverDeclarationFolders(string root) + { + if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Root '{root}' does not exist."); + + return [.. Directory.EnumerateFiles(root, "Entity.xml", SearchOption.AllDirectories) + .Select(f => Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(f)))) + .Where(d => !string.IsNullOrEmpty(d)) + .Select(d => Path.GetFullPath(d!)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)]; + } + + 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 +373,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 +391,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 +406,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,39 +443,35 @@ 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); } public static ParsedModel ParseModules(List modules) + => ParseModules(modules, null); + + public static ParsedModel ParseModules(List modules, ResolvedAppScope? appScope) { 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 @@ -379,7 +480,27 @@ row.RowType is not (RowType.Picklist or RowType.Multiselectoptionset or RowType. entity.SetName = entity.LogicalName; } - List EntityRelationships = ParseRelationships(modules, EntityTables); + // Before relationships: a table dropped here must not reappear as a stub created + // for a relationship that pointed at it. + if (appScope != null) + { + AppScopeFilter.ApplyTableScope(EntityTables, appScope); + } + + List EntityRelationships = ParseRelationships(modules, EntityTables, appScope); + + if (appScope != null) + { + // Option sets belonging to tables the scope removed would otherwise still be + // emitted, leaving more enum declarations in the output than columns using them. + var referenced = EntityTables + .SelectMany(t => t.Rows) + .Select(r => r.OptionSetName) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + EntityOptionSets.RemoveAll(o => !referenced.Contains(o.LocalizedName)); + } return new ParsedModel() { @@ -391,6 +512,20 @@ row.RowType is not (RowType.Picklist or RowType.Multiselectoptionset or RowType. } public static List ParseRelationships(List modules, List
EntityTables) + => ParseRelationships(modules, EntityTables, null); + + private static bool IsInAppScope(XElement relationship, ResolvedAppScope appScope) + { + if (relationship.Element("EntityRelationshipType")?.Value == "ManyToMany") + { + return appScope.TableLogicalNames.Contains(relationship.Element("FirstEntityName")?.Value ?? string.Empty) + || appScope.TableLogicalNames.Contains(relationship.Element("SecondEntityName")?.Value ?? string.Empty); + } + + return appScope.TableLogicalNames.Contains(relationship.Element("ReferencingEntityName")?.Value ?? string.Empty); + } + + public static List ParseRelationships(List modules, List
EntityTables, ResolvedAppScope? appScope) { List EntityRelationships = new(); @@ -401,6 +536,14 @@ public static List ParseRelationships(List modules, 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 +658,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/AppScopeTests.cs b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/AppScopeTests.cs new file mode 100644 index 00000000..cd32891d --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Data/DataModelConverter/AppScopeTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using TALXIS.CLI.Features.Data.DataModelConverter; +using TALXIS.CLI.Features.Data.DataModelConverter.AppScope; +using Model = TALXIS.CLI.Features.Data.DataModelConverter.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Data.DataModelConverter; + +/// +/// Resolving which tables a model-driven app is built on, from source alone. The shapes +/// exercised here are the ones real repositories actually contain: declarations under +/// "CDS" as well as "Declarations", one app declared across several files, and a folder +/// whose name differs in case from the UniqueName inside it. +/// +public class AppScopeTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "txc-app-" + Path.GetRandomFileName()); + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private string WriteAppModule(string folderName, string uniqueName, string componentsXml, string declarationsFolder = "Declarations") + { + var dir = Path.Combine(_root, "module", declarationsFolder, "AppModules", folderName); + Directory.CreateDirectory(dir); + var file = Path.Combine(dir, "AppModule_managed.xml"); + File.WriteAllText(file, $""" + + {uniqueName} + + {componentsXml} + + + """); + return file; + } + + private static string Component(string type, string schemaName) => + $""""""; + + // ---- resolution ------------------------------------------------------------------ + + [Fact] + public void OnlyEntityComponentsContributeTables() + { + WriteAppModule("contoso_app", "contoso_app", + Component("1", "contoso_thing") + + Component("26", "some_view") // saved query + + Component("60", "some_form") // system form + + Component("62", "contoso_app") // the app's own sitemap + + Component("1", "account")); + + var scope = AppScopeResolver.Resolve([_root], "contoso_app"); + + Assert.Equal(new[] { "account", "contoso_thing" }, scope.TableLogicalNames.OrderBy(x => x)); + } + + [Fact] + public void DeclarationsUnderCdsFolder_AreStillFound() + { + // Older modules keep their declarations under "CDS" rather than "Declarations"; + // a search anchored on either folder name misses the other. + WriteAppModule("contoso_app", "contoso_app", Component("1", "contoso_thing"), declarationsFolder: "CDS"); + + var scope = AppScopeResolver.Resolve([_root], "contoso_app"); + + Assert.Contains("contoso_thing", scope.TableLogicalNames); + } + + [Fact] + public void OneAppDeclaredAcrossSeveralFiles_UnionsItsComponents() + { + // A second area can contribute components to an app it does not own; the app's real + // component set is the union of every file that declares it. + WriteAppModule("contoso_app", "contoso_app", Component("1", "contoso_first")); + var second = Path.Combine(_root, "other", "Declarations", "AppModules", "contoso_app"); + Directory.CreateDirectory(second); + File.WriteAllText(Path.Combine(second, "AppModule_managed.xml"), """ + + contoso_app + + + + + """); + + var scope = AppScopeResolver.Resolve([_root], "contoso_app"); + + Assert.Contains("contoso_first", scope.TableLogicalNames); + Assert.Contains("contoso_second", scope.TableLogicalNames); + Assert.Equal(2, scope.SourceFiles.Count); + } + + [Fact] + public void IdentityComesFromFileContent_NotTheFolderName() + { + // The folder is only a locator, and its casing can differ from the declared name. + WriteAppModule("Contoso_App", "contoso_app", Component("1", "contoso_thing")); + + var scope = AppScopeResolver.Resolve([_root], "contoso_app"); + + Assert.Contains("contoso_thing", scope.TableLogicalNames); + } + + [Fact] + public void SiteMapEntities_AreIncluded_FromBothTheAttributeAndTheUrl() + { + WriteAppModule("contoso_app", "contoso_app", Component("1", "contoso_thing")); + var dir = Path.Combine(_root, "module", "Declarations", "AppModuleSiteMaps", "contoso_app"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "AppModuleSiteMap_managed.xml"), """ + + contoso_app + + + + """); + + var scope = AppScopeResolver.Resolve([_root], "contoso_app"); + + Assert.Contains("contoso_viaattribute", scope.TableLogicalNames); + Assert.Contains("contoso_viaurl", scope.TableLogicalNames); + } + + [Fact] + public void UnknownApp_FailsWithTheNamesItDidFind() + { + WriteAppModule("contoso_app", "contoso_app", Component("1", "contoso_thing")); + + var ex = Assert.Throws(() => AppScopeResolver.Resolve([_root], "contoso_typo")); + + Assert.Contains("contoso_app", ex.Message); + } + + // ---- filtering ------------------------------------------------------------------- + + private static XElement Entity(string logicalName) => + XElement.Parse($""" + + {logicalName} + + primarykey + lookup + + + """); + + private static XElement OneToMany(string child, string attr, string parent) => + XElement.Parse($""" + + OneToMany + {child} + {parent} + {attr} + + """); + + [Fact] + public void TablesOutsideTheApp_AreDropped_AndDoNotReturnAsRelationshipStubs() + { + // The ordering that matters: filtering after relationships were built would let a + // relationship among the dropped tables synthesise them straight back as stubs. + var module = new Model.Module { ModuleName = "test" }; + module.entities.AddRange([Entity("contoso_inapp"), Entity("contoso_elsewhere"), Entity("contoso_alsoelsewhere")]); + module.relationships.Add(OneToMany("contoso_elsewhere", "contoso_elsewhere_lookup", "contoso_alsoelsewhere")); + + var scope = new ResolvedAppScope { UniqueName = "contoso_app" }; + scope.TableLogicalNames.Add("contoso_inapp"); + + var model = DataModelConverterService.ParseModules([module], scope); + + Assert.Contains(model.tables, t => t.LogicalName == "contoso_inapp"); + Assert.DoesNotContain(model.tables, t => t.LogicalName == "contoso_elsewhere"); + Assert.DoesNotContain(model.tables, t => t.LogicalName == "contoso_alsoelsewhere"); + } + + [Fact] + public void ALookupOutOfTheApp_StillTerminates_SoTheEdgeIsNotLost() + { + var module = new Model.Module { ModuleName = "test" }; + module.entities.AddRange([Entity("contoso_inapp"), Entity("contoso_outside")]); + module.relationships.Add(OneToMany("contoso_inapp", "contoso_inapp_lookup", "contoso_outside")); + + var scope = new ResolvedAppScope { UniqueName = "contoso_app" }; + scope.TableLogicalNames.Add("contoso_inapp"); + + var model = DataModelConverterService.ParseModules([module], scope); + + Assert.Contains(model.relationships, r => r.LeftSideTable?.LogicalName == "contoso_inapp"); + Assert.Contains(model.tables, t => t.LogicalName == "contoso_outside" && t.Type == Model.TableType.NotInSolution); + } + + [Fact] + public void WithoutAnAppScope_NothingIsFiltered() + { + var module = new Model.Module { ModuleName = "test" }; + module.entities.AddRange([Entity("contoso_a"), Entity("contoso_b")]); + + var model = DataModelConverterService.ParseModules([module]); + + Assert.Contains(model.tables, t => t.LogicalName == "contoso_a"); + Assert.Contains(model.tables, t => t.LogicalName == "contoso_b"); + } +} 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); + } + } +}