Skip to content
Open
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
11 changes: 10 additions & 1 deletion src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> ExecuteAsync()
{
var inputPath = InputPath ?? Directory.GetCurrentDirectory();
Expand All @@ -52,7 +59,9 @@ 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);
var includeAttributes = AttributeFilter.ParsePatterns(IncludeAttributes);

DataModelConverterService.ConvertModel(inputPath, TargetFormat!, outputFilePath, includeAttributes);

OutputFormatter.WriteResult("succeeded", $"Output written to: {outputFilePath}");
return Task.FromResult(ExitSuccess);
Expand Down
65 changes: 65 additions & 0 deletions src/TALXIS.CLI.Features.Data/DataModelConverter/AttributeFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
using TALXIS.CLI.Features.Data.DataModelConverter.Model;

namespace TALXIS.CLI.Features.Data.DataModelConverter;

/// <summary>
/// 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.
/// </summary>
public static class AttributeFilter
{
/// <summary>
/// Removes every column whose name does not match one of <paramref name="includePatterns"/>.
/// Patterns are case-insensitive and support the <c>*</c> (any sequence) and <c>?</c>
/// (single character) glob wildcards, e.g. <c>myprefix_*</c>, <c>ownerid</c>.
/// </summary>
public static void Apply(ParsedModel model, IReadOnlyCollection<string>? 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<TableRow>();
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<string> ParsePatterns(string? commaSeparated)
{
if (string.IsNullOrWhiteSpace(commaSeparated)) return Array.Empty<string>();

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public class DataModelConverterService
/// <item>A <c>.zip</c> file — decoded and parsed as an exported solution package.</item>
/// </list>
/// </remarks>
public static void ConvertModel(string inputPath, string targetFormat, string outputFilePath)
public static void ConvertModel(string inputPath, string targetFormat, string outputFilePath, IReadOnlyCollection<string>? includeAttributes = null)
{
if (!SupportedFormats.Contains(targetFormat.ToLower()))
throw new ArgumentException($"Unsupported target format '{targetFormat}'. Supported formats are: {string.Join(", ", SupportedFormats)}.");
Expand All @@ -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),
Expand Down
138 changes: 138 additions & 0 deletions tests/TALXIS.CLI.Tests/Data/AttributeFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<System.ArgumentNullException>(() => 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);
}
}