Skip to content
Draft
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
66 changes: 62 additions & 4 deletions src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string> 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",
Expand All @@ -43,7 +57,33 @@ public class DataModelConvertCliCommand : TxcLeafCommand

protected override Task<int> ExecuteAsync()
{
var inputPath = InputPath ?? Directory.GetCurrentDirectory();
var inputPaths = new List<string>(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<string>(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);
Expand All @@ -52,12 +92,30 @@ protected override Task<int> 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);
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Ensures the exports folder is listed in the nearest .gitignore,
/// adding an entry if it is not already present.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Narrows a parsed model to the tables an app is built on.</summary>
public static class AppScopeFilter
{
private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AppScopeFilter));

/// <summary>
/// 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.
/// </summary>
public static void ApplyTableScope(List<Table> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The tables a model-driven app is built on, resolved from source.</summary>
public class ResolvedAppScope
{
public string UniqueName { get; init; } = string.Empty;

/// <summary>Compared case-insensitively: an app component's schemaName casing is not
/// guaranteed to match the casing of the entity's own declaration.</summary>
public HashSet<string> TableLogicalNames { get; } = new(StringComparer.OrdinalIgnoreCase);

/// <summary>Every file that contributed, for reporting which sources were read.</summary>
public List<string> SourceFiles { get; } = [];
}

/// <summary>
/// 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.
/// </summary>
public static class AppScopeResolver
{
private static readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AppScopeResolver));

private const string AppModulesFolder = "AppModules";
private const string SiteMapsFolder = "AppModuleSiteMaps";

/// <summary>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.</summary>
private const string EntityComponentType = "1";

public static ResolvedAppScope Resolve(IEnumerable<string> 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;
}

/// <summary>
/// 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.
/// </summary>
public static Dictionary<string, List<string>> DiscoverAppModules(IEnumerable<string> searchRoots)
{
var byName = new Dictionary<string, List<string>>(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;
}

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> ResolveSiteMapTables(IEnumerable<string> 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..];
}
}
}
}
}

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> FilesUnder(IEnumerable<string> searchRoots, string folderName, string pattern)
{
var seen = new HashSet<string>(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;
}
}
}
Loading