From f03d173ce8aea416767569d22a0acd722d7a389e Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 29 Jun 2026 12:49:29 +0200 Subject: [PATCH] feat: filter attributes when converting a solution data model --- .../DataModelConvertCliCommand.cs | 11 +- .../DataModelConverter/AttributeFilter.cs | 65 +++++++++ .../DataModelConverterService.cs | 7 +- .../Data/AttributeFilterTests.cs | 138 ++++++++++++++++++ 4 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Data/DataModelConverter/AttributeFilter.cs create mode 100644 tests/TALXIS.CLI.Tests/Data/AttributeFilterTests.cs diff --git a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs index a42eee0a..19e6927d 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs @@ -41,6 +41,13 @@ public class DataModelConvertCliCommand : TxcLeafCommand )] public string? OutputDirectory { get; set; } + [CliOption( + Name = "--include-attributes", + Description = "Comma-separated list of attribute name patterns to include, e.g. \"myprefix_*,ownerid,statecode\". Supports '*' and '?' wildcards (case-insensitive). When omitted, every attribute is included. Primary keys and the columns backing relationships are always kept so the diagram stays valid.", + Required = false + )] + public string? IncludeAttributes { get; set; } + protected override Task ExecuteAsync() { var inputPath = InputPath ?? Directory.GetCurrentDirectory(); @@ -52,7 +59,9 @@ protected override Task ExecuteAsync() var extension = TargetFormat!.ToLower() == "plainsql" ? "sql" : TargetFormat.ToLower(); var outputFilePath = Path.Combine(outputDir, $"solution.{extension}"); - DataModelConverterService.ConvertModel(inputPath, TargetFormat!, outputFilePath); + var includeAttributes = AttributeFilter.ParsePatterns(IncludeAttributes); + + DataModelConverterService.ConvertModel(inputPath, TargetFormat!, outputFilePath, includeAttributes); OutputFormatter.WriteResult("succeeded", $"Output written to: {outputFilePath}"); return Task.FromResult(ExitSuccess); diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/AttributeFilter.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/AttributeFilter.cs new file mode 100644 index 00000000..8ba179ca --- /dev/null +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/AttributeFilter.cs @@ -0,0 +1,65 @@ +using System.Text.RegularExpressions; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; + +namespace TALXIS.CLI.Features.Data.DataModelConverter; + +/// +/// Filters the attributes (columns) of a parsed data model down to those matching a set of +/// name patterns, while preserving the columns the diagram needs to stay coherent: primary keys +/// and any column that backs a relationship. +/// +public static class AttributeFilter +{ + /// + /// Removes every column whose name does not match one of . + /// Patterns are case-insensitive and support the * (any sequence) and ? + /// (single character) glob wildcards, e.g. myprefix_*, ownerid. + /// + public static void Apply(ParsedModel model, IReadOnlyCollection? includePatterns) + { + ArgumentNullException.ThrowIfNull(model, nameof(model)); + ArgumentNullException.ThrowIfNull(includePatterns, nameof(includePatterns)); + + var matchers = includePatterns + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(GlobToRegex) + .ToList(); + + if (matchers.Count == 0) return; + + var relationshipColumns = new HashSet(); + foreach (var relationship in model.relationships) + { + if (relationship.LeftSideRow != null) relationshipColumns.Add(relationship.LeftSideRow); + if (relationship.RighSideRow != null) relationshipColumns.Add(relationship.RighSideRow); + } + + foreach (var table in model.tables) + { + table.Rows = table.Rows + .Where(row => + row.RowType == RowType.Primarykey + || relationshipColumns.Contains(row) + || matchers.Any(rx => rx.IsMatch(row.Name))) + .ToList(); + } + } + + public static IReadOnlyList ParsePatterns(string? commaSeparated) + { + if (string.IsNullOrWhiteSpace(commaSeparated)) return Array.Empty(); + + return commaSeparated + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + + private static Regex GlobToRegex(string pattern) + { + var escaped = Regex.Escape(pattern.Trim()) + .Replace("\\*", ".*") + .Replace("\\?", "."); + + return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } +} diff --git a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs index 36a1d17c..f05edf86 100644 --- a/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs @@ -34,7 +34,7 @@ public class DataModelConverterService /// A .zip file — decoded and parsed as an exported solution package. /// /// - public static void ConvertModel(string inputPath, string targetFormat, string outputFilePath) + public static void ConvertModel(string inputPath, string targetFormat, string outputFilePath, IReadOnlyCollection? includeAttributes = null) { if (!SupportedFormats.Contains(targetFormat.ToLower())) throw new ArgumentException($"Unsupported target format '{targetFormat}'. Supported formats are: {string.Join(", ", SupportedFormats)}."); @@ -58,6 +58,11 @@ public static void ConvertModel(string inputPath, string targetFormat, string ou throw new FileNotFoundException($"Input path '{inputPath}' does not exist."); } + if (includeAttributes != null && includeAttributes.Count > 0) + { + AttributeFilter.Apply(parsedModel, includeAttributes); + } + var resultString = targetFormat.ToLower() switch { "edmx" => ConvertToEDMX(parsedModel), diff --git a/tests/TALXIS.CLI.Tests/Data/AttributeFilterTests.cs b/tests/TALXIS.CLI.Tests/Data/AttributeFilterTests.cs new file mode 100644 index 00000000..726ce71b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Data/AttributeFilterTests.cs @@ -0,0 +1,138 @@ +using System.Linq; +using TALXIS.CLI.Features.Data.DataModelConverter; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Data; + +public class AttributeFilterTests +{ + private static Table BuildTable() + { + return new Table + { + LogicalName = "myprefix_account", + LocalizedName = "Account", + SetName = "myprefix_accounts", + Rows = + { + new TableRow("myprefix_accountid", RowType.Primarykey), + new TableRow("myprefix_name", RowType.Nvarchar), + new TableRow("myprefix_amount", RowType.Money), + new TableRow("ownerid", RowType.Owner), + new TableRow("statecode", RowType.State), + new TableRow("createdon", RowType.Datetimeoffset), + new TableRow("modifiedby", RowType.Lookup), + } + }; + } + + private static ParsedModel BuildModel(Table table) + { + return new ParsedModel + { + tables = { table }, + relationships = { }, + optionSets = { } + }; + } + + [Fact] + public void Apply_NullPatterns_Throws() + { + var model = BuildModel(BuildTable()); + + Assert.Throws(() => AttributeFilter.Apply(model, null)); + } + + [Fact] + public void Apply_EmptyPatterns_KeepsEveryColumn() + { + var table = BuildTable(); + var model = BuildModel(table); + + AttributeFilter.Apply(model, AttributeFilter.ParsePatterns("")); + + Assert.Equal(7, table.Rows.Count); + } + + [Fact] + public void Apply_PrefixWildcardPlusExactNames_KeepsOnlyMatches() + { + var table = BuildTable(); + var model = BuildModel(table); + + AttributeFilter.Apply(model, AttributeFilter.ParsePatterns("myprefix_*,ownerid,statecode")); + + var kept = table.Rows.Select(r => r.Name).ToHashSet(); + Assert.Contains("myprefix_name", kept); + Assert.Contains("myprefix_amount", kept); + Assert.Contains("ownerid", kept); + Assert.Contains("statecode", kept); + Assert.DoesNotContain("createdon", kept); + } + + [Fact] + public void Apply_AlwaysKeepsPrimaryKey_EvenWhenUnmatched() + { + var table = BuildTable(); + var model = BuildModel(table); + + // A pattern that matches nothing - the PK must still survive. + AttributeFilter.Apply(model, AttributeFilter.ParsePatterns("zzz_*")); + + Assert.Single(table.Rows); + Assert.Equal(RowType.Primarykey, table.Rows[0].RowType); + } + + [Fact] + public void Apply_KeepsColumnsBackingRelationships_EvenWhenUnmatched() + { + var left = BuildTable(); + var right = new Table + { + LogicalName = "systemuser", + LocalizedName = "User", + SetName = "systemusers", + Rows = { new TableRow("systemuserid", RowType.Primarykey) } + }; + + var lookupRow = left.Rows.First(r => r.Name == "modifiedby"); + var pkRow = right.Rows[0]; + + var model = new ParsedModel + { + tables = { left, right }, + relationships = + { + new Relationship("myprefix_account_modifiedby", "ManyToOne", left, lookupRow, right, pkRow) + }, + optionSets = { } + }; + + // "modifiedby" does not match, but it backs a relationship -> must survive. + AttributeFilter.Apply(model, AttributeFilter.ParsePatterns("myprefix_*")); + + Assert.Contains(left.Rows, r => r.Name == "modifiedby"); + Assert.Contains(right.Rows, r => r.Name == "systemuserid"); + } + + [Fact] + public void Apply_IsCaseInsensitive() + { + var table = BuildTable(); + var model = BuildModel(table); + + AttributeFilter.Apply(model, AttributeFilter.ParsePatterns("MYPREFIX_NAME")); + + Assert.Contains(table.Rows, r => r.Name == "myprefix_name"); + } + + [Fact] + public void ParsePatterns_TrimsAndDropsBlankEntries() + { + var patterns = AttributeFilter.ParsePatterns(" myprefix_* , , ownerid "); + + Assert.Equal(new[] { "myprefix_*", "ownerid" }, patterns); + } +}