diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs index b7d0683b..a502a539 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs @@ -26,7 +26,8 @@ public sealed record EntityAttributeRecord( int? MaxLength, string? Description, string? OptionSetName = null, - string? OptionValues = null); + string? OptionValues = null, + string? RequiredLevel = null); /// /// Relationship summary for an entity, returned by diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs index b9ad9669..18d62721 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs @@ -9,7 +9,7 @@ namespace TALXIS.CLI.Features.Environment.Entity; [CliCommand( Name = "entity", Description = "Entity discovery and schema metadata for the live environment.", - Children = new[] { typeof(EntityListCliCommand), typeof(EntityDescribeCliCommand), typeof(EntityGetCliCommand), typeof(EntityUpdateCliCommand), typeof(EntityCreateCliCommand), typeof(EntityDeleteCliCommand), typeof(EntityAttributeCliCommand), typeof(EntityRelationshipCliCommand) } + Children = new[] { typeof(EntityListCliCommand), typeof(EntityDescribeCliCommand), typeof(EntityExploreCliCommand), typeof(EntityGetCliCommand), typeof(EntityUpdateCliCommand), typeof(EntityCreateCliCommand), typeof(EntityDeleteCliCommand), typeof(EntityAttributeCliCommand), typeof(EntityRelationshipCliCommand) } )] public class EntityCliCommand { diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreCliCommand.cs new file mode 100644 index 00000000..1cdc0de3 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreCliCommand.cs @@ -0,0 +1,257 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Entity; + +/// A form on the explored entity (name + form type display name). +public sealed record EntityExploreForm(string Name, string Type); + +/// Aggregated single-view snapshot of an entity returned by entity explore. +public sealed record EntityExploreResult( + EntityDetailRecord Entity, + IReadOnlyList Columns, + int CustomColumnCount, + int SystemColumnCount, + IReadOnlyList Relationships, + long? RecordCount, + IReadOnlyList Forms, + int? ViewCount); + +/// +/// txc environment entity explore - columns with option set values, +/// relationships, and record/form/view counts in one view. +/// +[CliReadOnly] +[CliCommand( + Name = "explore", + Description = "Shows a Dataverse table at a glance from the LIVE connected environment: columns with option set values expanded inline, relationships, and record/form/view counts. Requires an active profile. Use instead of separate describe/optionset/relationship calls when you want the full picture of a table." +)] +public class EntityExploreCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityExploreCliCommand)); + + [CliArgument(Name = "entity", Description = "The logical name of the entity to explore.")] + public string Entity { get; set; } = null!; + + [CliOption(Name = "--columns-only", Description = "Skip relationships and record/form/view counts.", Required = false)] + public bool ColumnsOnly { get; set; } + + [CliOption(Name = "--include-system", Description = "Show system columns in the table (the summary counts always include them).", Required = false)] + public bool IncludeSystem { get; set; } + + protected override async Task ExecuteAsync() + { + var metadata = TxcServices.Get(); + + var detail = await metadata.GetEntityDetailAsync(Profile, Entity, CancellationToken.None).ConfigureAwait(false); + var allColumns = await metadata.DescribeEntityAsync(Profile, Entity, includeSystem: true, CancellationToken.None).ConfigureAwait(false); + + int customColumnCount = allColumns.Count(column => column.IsCustomAttribute); + int systemColumnCount = allColumns.Count - customColumnCount; + var columns = IncludeSystem ? allColumns : allColumns.Where(column => column.IsCustomAttribute).ToList(); + + IReadOnlyList relationships = Array.Empty(); + long? recordCount = null; + IReadOnlyList forms = Array.Empty(); + int? viewCount = null; + + if (!ColumnsOnly) + { + relationships = await metadata.ListRelationshipsAsync(Profile, Entity, CancellationToken.None).ConfigureAwait(false); + + var query = TxcServices.Get(); + recordCount = await TryCountRecordsAsync(query, detail).ConfigureAwait(false); + forms = await TryListFormsAsync(query).ConfigureAwait(false); + viewCount = await TryCountViewsAsync(query).ConfigureAwait(false); + } + + var result = new EntityExploreResult( + detail, columns, customColumnCount, systemColumnCount, + relationships, recordCount, forms, viewCount); + + OutputFormatter.WriteData(result, Print); + return ExitSuccess; + } + + private async Task TryCountRecordsAsync(IDataverseQueryService query, EntityDetailRecord detail) + { + var primaryId = detail.PrimaryIdAttribute ?? $"{Entity}id"; + var fetchXml = + $"" + + $"" + + ""; + try + { + var result = await query.QueryFetchXmlAsync(Profile, fetchXml, top: null, includeAnnotations: false, CancellationToken.None).ConfigureAwait(false); + if (result.Records.Count > 0 && result.Records[0].TryGetProperty("recordcount", out var countElement)) + { + return countElement.GetInt64(); + } + return null; + } + catch (Exception ex) + { + Logger.LogWarning("Record count unavailable: {Message}", ex.Message); + + return null; + } + } + + private async Task> TryListFormsAsync(IDataverseQueryService query) + { + try + { + var result = await query.QueryODataAsync( + Profile, "systemforms", + select: "name,type", + filter: $"objecttypecode eq '{Entity}'", + orderBy: "name", top: null, includeAnnotations: false, CancellationToken.None).ConfigureAwait(false); + + return result.Records + .Select(record => new EntityExploreForm( + record.TryGetProperty("name", out var name) ? name.GetString() ?? "?" : "?", + record.TryGetProperty("type", out var type) && type.ValueKind == System.Text.Json.JsonValueKind.Number + ? EntityExploreHelpers.FormTypeName(type.GetInt32()) + : "?")) + .ToList(); + } + catch (Exception ex) + { + Logger.LogWarning("Form list unavailable: {Message}", ex.Message); + return Array.Empty(); + } + } + + private async Task TryCountViewsAsync(IDataverseQueryService query) + { + try + { + var result = await query.QueryODataAsync( + Profile, "savedqueries", + select: "savedqueryid", + filter: $"returnedtypecode eq '{Entity}'", + orderBy: null, top: null, includeAnnotations: false, CancellationToken.None).ConfigureAwait(false); + return result.Records.Count; + } + catch (Exception ex) + { + Logger.LogWarning("View count unavailable: {Message}", ex.Message); + return null; + } + } + +#pragma warning disable TXC003 + private static void Print(EntityExploreResult result) + { + var title = result.Entity.DisplayName is { } displayName + ? $"{result.Entity.LogicalName} - {displayName}" + : result.Entity.LogicalName; + OutputWriter.WriteLine(title); + OutputWriter.WriteLine(new string('=', title.Length)); + OutputWriter.WriteLine(""); + + PrintColumns(result); + + if (result.Relationships.Count > 0) + { + OutputWriter.WriteLine(""); + PrintRelationships(result); + } + + var footer = BuildFooter(result); + if (footer.Length > 0) + { + OutputWriter.WriteLine(""); + OutputWriter.WriteLine(footer); + } + } + + private static void PrintColumns(EntityExploreResult result) + { + OutputWriter.WriteLine($"Columns ({result.CustomColumnCount} custom + {result.SystemColumnCount} system)"); + if (result.Columns.Count == 0) + { + OutputWriter.WriteLine("No columns to show."); + return; + } + + int logicalWidth = Math.Clamp(result.Columns.Max(column => column.LogicalName.Length), 12, 48); + int typeWidth = Math.Clamp(result.Columns.Max(column => column.AttributeTypeName.Length), 4, 30); + int displayWidth = Math.Clamp(result.Columns.Max(column => (column.DisplayName ?? "").Length), 12, 40); + int requiredWidth = 11; + + string header = + $"{"Logical Name".PadRight(logicalWidth)} | " + + $"{"Type".PadRight(typeWidth)} | " + + $"{"Display Name".PadRight(displayWidth)} | " + + $"{"Required".PadRight(requiredWidth)}"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var column in result.Columns) + { + string line = + $"{Truncate(column.LogicalName, logicalWidth).PadRight(logicalWidth)} | " + + $"{Truncate(column.AttributeTypeName, typeWidth).PadRight(typeWidth)} | " + + $"{Truncate(column.DisplayName ?? "", displayWidth).PadRight(displayWidth)} | " + + $"{EntityExploreHelpers.RequiredDisplay(column.RequiredLevel).PadRight(requiredWidth)}"; + OutputWriter.WriteLine(line); + + foreach (var optionLine in EntityExploreHelpers.OptionLines(column.OptionValues)) + { + OutputWriter.WriteLine($" └─ {optionLine}"); + } + } + } + + private static void PrintRelationships(EntityExploreResult result) + { + OutputWriter.WriteLine($"Relationships ({result.Relationships.Count})"); + + int nameWidth = Math.Clamp(result.Relationships.Max(relationship => relationship.SchemaName.Length), 12, 50); + int typeWidth = 4; + + string header = + $"{"Name".PadRight(nameWidth)} | " + + $"{"Type".PadRight(typeWidth)} | " + + "Related Entity"; + + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length + 16)); + + foreach (var relationship in result.Relationships) + { + string related = EntityExploreHelpers.RelatedEntity(relationship, result.Entity.LogicalName); + string line = + $"{Truncate(relationship.SchemaName, nameWidth).PadRight(nameWidth)} | " + + $"{EntityExploreHelpers.RelationshipTypeShort(relationship.RelationshipType).PadRight(typeWidth)} | " + + related; + + OutputWriter.WriteLine(line); + } + } + + private static string BuildFooter(EntityExploreResult result) + { + var parts = new List(); + + if (result.RecordCount is { } recordCount) parts.Add($"Records: {recordCount}"); + + if (result.Forms.Count > 0) + { + var types = string.Join(", ", result.Forms.Select(form => form.Type).Distinct()); + parts.Add($"Forms: {result.Forms.Count} ({types})"); + } + + if (result.ViewCount is { } viewCount) parts.Add($"Views: {viewCount}"); + + return string.Join(" | ", parts); + } +#pragma warning restore TXC003 + + private static string Truncate(string value, int maxWidth) => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; +} diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreHelpers.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreHelpers.cs new file mode 100644 index 00000000..9f111548 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Entity/EntityExploreHelpers.cs @@ -0,0 +1,63 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Entity; + +/// +/// Pure formatting helpers for entity explore output. +/// +public static class EntityExploreHelpers +{ + /// + /// Splits the compact "value:label, value:label" option string produced by + /// entity metadata into display lines like "375970000 = Box". + /// + public static IReadOnlyList OptionLines(string? optionValues) + { + if (string.IsNullOrWhiteSpace(optionValues)) return Array.Empty(); + + return optionValues + .Split(", ", StringSplitOptions.RemoveEmptyEntries) + .Select(pair => + { + var parts = pair.Split(':', 2); + return parts.Length == 2 ? $"{parts[0]} = {parts[1]}" : pair; + }) + .ToList(); + } + + /// Maps the metadata RequiredLevel to the short display used in the columns table. + public static string RequiredDisplay(string? requiredLevel) => requiredLevel switch + { + "ApplicationRequired" or "SystemRequired" => "Required", + "Recommended" => "Recommended", + _ => "" + }; + + /// Maps the relationship type name to the compact 1:N / N:1 / N:N form. + public static string RelationshipTypeShort(string relationshipType) => relationshipType switch + { + "OneToMany" => "1:N", + "ManyToOne" => "N:1", + "ManyToMany" => "N:N", + _ => relationshipType + }; + + /// Returns the entity on the other side of the relationship from . + public static string RelatedEntity(EntityRelationshipRecord relationship, string self) + => string.Equals(relationship.Entity1LogicalName, self, StringComparison.OrdinalIgnoreCase) + ? relationship.Entity2LogicalName + : relationship.Entity1LogicalName; + + /// Maps a systemform type code to its display name. + public static string FormTypeName(int formType) => formType switch + { + 2 => "Main", + 5 => "Mobile", + 6 => "Quick View", + 7 => "Quick Create", + 8 => "Dialog", + 11 => "Card", + 12 => "Main Interactive", + _ => $"Type {formType}" + }; +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs index 21f5f26b..78df05c5 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs @@ -123,7 +123,8 @@ public async Task> DescribeEntityAsync( MaxLength: a is StringAttributeMetadata strAttr ? strAttr.MaxLength : null, Description: a.Description?.UserLocalizedLabel?.Label, OptionSetName: optionSetName, - OptionValues: optionValues); + OptionValues: optionValues, + RequiredLevel: a.RequiredLevel?.Value.ToString()); }) .ToList(); } diff --git a/tests/TALXIS.CLI.Tests/Environment/Entity/EntityExploreHelpersTests.cs b/tests/TALXIS.CLI.Tests/Environment/Entity/EntityExploreHelpersTests.cs new file mode 100644 index 00000000..8d038233 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Entity/EntityExploreHelpersTests.cs @@ -0,0 +1,80 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Entity; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.EntityExplore; + +/// +/// Unit tests for pure formatting logic. +/// +public class EntityExploreHelpersTests +{ + [Fact] + public void OptionLines_ParsesValueLabelPairs() + { + var lines = EntityExploreHelpers.OptionLines("375970000:Box, 375970001:Bag, 375970002:Envelope"); + + Assert.Equal(new[] { "375970000 = Box", "375970001 = Bag", "375970002 = Envelope" }, lines); + } + + [Fact] + public void OptionLines_KeepsColonInsideLabel() + { + var lines = EntityExploreHelpers.OptionLines("1:Ratio 1:10"); + + Assert.Equal(new[] { "1 = Ratio 1:10" }, lines); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void OptionLines_EmptyInput_ReturnsEmpty(string? optionValues) + { + Assert.Empty(EntityExploreHelpers.OptionLines(optionValues)); + } + + [Theory] + [InlineData("ApplicationRequired", "Required")] + [InlineData("SystemRequired", "Required")] + [InlineData("Recommended", "Recommended")] + [InlineData("None", "")] + [InlineData(null, "")] + public void RequiredDisplay_MapsLevels(string? requiredLevel, string expected) + { + Assert.Equal(expected, EntityExploreHelpers.RequiredDisplay(requiredLevel)); + } + + [Theory] + [InlineData("OneToMany", "1:N")] + [InlineData("ManyToOne", "N:1")] + [InlineData("ManyToMany", "N:N")] + [InlineData("Weird", "Weird")] + public void RelationshipTypeShort_MapsTypes(string relationshipType, string expected) + { + Assert.Equal(expected, EntityExploreHelpers.RelationshipTypeShort(relationshipType)); + } + + [Fact] + public void RelatedEntity_ReturnsTheOtherSide() + { + var oneToMany = new EntityRelationshipRecord( + "item_transactions", "OneToMany", "udpp_item", "udpp_transaction", true, null); + var manyToOne = new EntityRelationshipRecord( + "item_owner", "ManyToOne", "udpp_item", "systemuser", true, null); + + Assert.Equal("udpp_transaction", EntityExploreHelpers.RelatedEntity(oneToMany, "udpp_item")); + Assert.Equal("systemuser", EntityExploreHelpers.RelatedEntity(manyToOne, "udpp_item")); + Assert.Equal("udpp_item", EntityExploreHelpers.RelatedEntity(oneToMany, "udpp_transaction")); + } + + [Theory] + [InlineData(2, "Main")] + [InlineData(7, "Quick Create")] + [InlineData(11, "Card")] + [InlineData(99, "Type 99")] + public void FormTypeName_MapsKnownCodes(int formType, string expected) + { + Assert.Equal(expected, EntityExploreHelpers.FormTypeName(formType)); + } +}