From 0d21af555a87b314d5db34fd49dde39457bf3543 Mon Sep 17 00:00:00 2001 From: Alberto Spelta Date: Wed, 18 Dec 2024 14:00:06 +0100 Subject: [PATCH] Add `browse` and `package` commands to CLI tool (#187) * Update csproj set LangVersion 12, remove ImplicitUsings, add GolobalUsings.cs * Moved ExportCommand classes to new subfolder * Add `package` and `browse` commands * Update README.md --- src/Dax.Vpax.CLI/.editorconfig | 190 ++++++++++++++++++ .../Commands/Browse/BrowseColumnCommand.cs | 15 ++ .../Browse/BrowseColumnCommandHandler.cs | 75 +++++++ .../Commands/Browse/BrowseCommand.cs | 20 ++ .../Commands/Browse/BrowseModelCommand.cs | 13 ++ .../Browse/BrowseModelCommandHandler.cs | 61 ++++++ .../Browse/BrowseRelationshipCommand.cs | 15 ++ .../BrowseRelationshipCommandHandler.cs | 64 ++++++ .../Commands/Browse/BrowseTableCommand.cs | 15 ++ .../Browse/BrowseTableCommandHandler.cs | 75 +++++++ .../Commands/Browse/CommonOptions.cs | 27 +++ src/Dax.Vpax.CLI/Commands/CommandHandler.cs | 48 +++++ src/Dax.Vpax.CLI/Commands/CommonOptions.cs | 9 + .../Commands/Export/ExportCommand.cs | 23 +++ .../{ => Export}/ExportCommandHandler.cs | 16 +- .../{ => Export}/ExportCommandOptions.cs | 7 +- src/Dax.Vpax.CLI/Commands/ExportCommand.cs | 27 --- .../Commands/Package/PackageCommand.cs | 15 ++ .../Commands/Package/PackageExtractCommand.cs | 29 +++ .../Package/PackageExtractCommandHandler.cs | 36 ++++ .../Commands/Package/PackageSetCommand.cs | 22 ++ .../Package/PackageSetCommandHandler.cs | 23 +++ .../Commands/Package/PackageShowCommand.cs | 14 ++ .../Package/PackageShowCommandHandler.cs | 68 +++++++ .../Commands/Package/PackageUnsetCommand.cs | 12 ++ .../Package/PackageUnsetCommandHandler.cs | 13 ++ src/Dax.Vpax.CLI/Commands/RootVpaxCommand.cs | 14 ++ src/Dax.Vpax.CLI/Dax.Vpax.CLI.csproj | 7 +- .../CommandLineBuilderExtensions.cs | 41 ++++ .../Extensions/CommonExtensions.cs | 50 +++++ src/Dax.Vpax.CLI/GlobalUsings.cs | 18 ++ src/Dax.Vpax.CLI/Program.cs | 15 +- .../Properties/launchSettings.json | 3 +- src/Dax.Vpax.CLI/README.md | 6 +- src/Dax.Vpax.CLI/UserSession.cs | 35 ++++ 35 files changed, 1063 insertions(+), 58 deletions(-) create mode 100644 src/Dax.Vpax.CLI/.editorconfig create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Browse/CommonOptions.cs create mode 100644 src/Dax.Vpax.CLI/Commands/CommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/CommonOptions.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Export/ExportCommand.cs rename src/Dax.Vpax.CLI/Commands/{ => Export}/ExportCommandHandler.cs (83%) rename src/Dax.Vpax.CLI/Commands/{ => Export}/ExportCommandOptions.cs (94%) delete mode 100644 src/Dax.Vpax.CLI/Commands/ExportCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageSetCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageSetCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageShowCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageShowCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommand.cs create mode 100644 src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommandHandler.cs create mode 100644 src/Dax.Vpax.CLI/Commands/RootVpaxCommand.cs create mode 100644 src/Dax.Vpax.CLI/Extensions/CommandLineBuilderExtensions.cs create mode 100644 src/Dax.Vpax.CLI/Extensions/CommonExtensions.cs create mode 100644 src/Dax.Vpax.CLI/GlobalUsings.cs create mode 100644 src/Dax.Vpax.CLI/UserSession.cs diff --git a/src/Dax.Vpax.CLI/.editorconfig b/src/Dax.Vpax.CLI/.editorconfig new file mode 100644 index 0000000..52a565a --- /dev/null +++ b/src/Dax.Vpax.CLI/.editorconfig @@ -0,0 +1,190 @@ +# editorconfig.org + +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# Generated code +[*{_AssemblyInfo.cs,.notsupported.cs,AsmOffsets.cs}] +generated_code = true + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Types: use keywords instead of BCL types, and always suggest var +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true:none +csharp_preserve_single_line_statements = false:none +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_primary_constructors = false:none +csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# License header +# file_header_template = + +# C++ Files +[*.{cpp,h,in}] +curly_bracket_next_line = true +indent_brace_style = Allman + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +[*.{csproj,vbproj,proj,nativeproj,locproj}] +charset = utf-8 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +# YAML config files +[*.{yml,yaml}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf +[*.{cmd,bat}] +end_of_line = crlf \ No newline at end of file diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommand.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommand.cs new file mode 100644 index 0000000..2c7e3eb --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommand.cs @@ -0,0 +1,15 @@ +using Dax.Vpax.CLI.Commands.Package; + +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseColumnCommand : Command +{ + public static BrowseColumnCommand Instance { get; } = new BrowseColumnCommand(); + + private BrowseColumnCommand() + : base(name: "column", description: "Display columns information") + { + AddAlias("c"); + Handler = new BrowseColumnCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommandHandler.cs new file mode 100644 index 0000000..caa6733 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseColumnCommandHandler.cs @@ -0,0 +1,75 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseColumnCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var model = GetCurrentModel(context); + if (model is null) + return Task.FromResult(context.ExitCode); + + AnsiConsole.Write(GetColumns(context, model)); + return Task.FromResult(context.ExitCode); + } + + private IRenderable GetColumns(InvocationContext context, Metadata.Model model) + { + var top = context.ParseResult.GetValueForOption(CommonOptions.TopOption); + var excludeHidden = context.ParseResult.GetValueForOption(CommonOptions.ExcludeHiddenOption); + var orderBy = context.ParseResult.GetValueForOption(CommonOptions.OrderByOption); + + var table = new Spectre.Console.Table().BorderColor(Color.Yellow) + .AddColumn(new TableColumn(new Markup("[yellow]Name[/]").Centered()).NoWrap()) + .AddColumn(new TableColumn(new Markup("[yellow]Cardinality[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Size %[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Data Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Dictionary Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Hierarchies Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Encoding[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Data Type[/]").Centered())); + + var query = model.Tables.SelectMany((t) => t.Columns).Where(c => !c.IsRowNumber); + + query = orderBy switch + { + "name" or "n" => query.OrderBy((c) => c.ToDisplayName()), + "cardinality" or "c" => query.OrderByDescending((c) => c.ColumnCardinality), + "size" or "s" => query.OrderByDescending((c) => c.TotalSize), + _ => query + }; + + if (excludeHidden) query = query.Where((c) => !c.IsHidden); + if (top.HasValue) query = query.Take(top.Value); + + var modelSize = model.Tables.Sum((t) => t.TableSize); + var columns = query.ToArray(); + + foreach (var c in columns) + { + var style = c.IsHidden ? new Style(foreground: Color.Grey) : Style.Plain; + var sizePercentage = (double)c.TotalSize / modelSize; + + table.AddRow( + new Text(c.ToDisplayName(), style).LeftJustified(), + new Text(c.ColumnCardinality.ToString("N0"), style).RightJustified(), + new Text(c.TotalSize.ToString("N0"), style).RightJustified(), + new Text(sizePercentage.ToString("P2"), style).RightJustified(), + new Text(c.DataSize.ToString("N0"), style).RightJustified(), + new Text(c.DictionarySize.ToString("N0"), style).RightJustified(), + new Text(c.HierarchiesSize.ToString("N0"), style).RightJustified(), + new Text(c.Encoding, style).RightJustified(), + new Text(c.DataType, style).RightJustified() + ); + } + + table.Columns[0].Footer = new Markup($"[grey]{columns.Length:N0} items[/]").LeftJustified(); + table.Columns[1].Footer = new Markup($"[grey]{columns.Sum(_ => _.ColumnCardinality):N0}[/]").RightJustified(); + table.Columns[2].Footer = new Markup($"[grey]{columns.Sum(_ => _.TotalSize).ToSizeString():N0}[/]").RightJustified(); + table.Columns[4].Footer = new Markup($"[grey]{columns.Sum(_ => _.DataSize).ToSizeString():N0}[/]").RightJustified(); + table.Columns[5].Footer = new Markup($"[grey]{columns.Sum(_ => _.DictionarySize).ToSizeString():N0}[/]").RightJustified(); + table.Columns[6].Footer = new Markup($"[grey]{columns.Sum(_ => _.HierarchiesSize).ToSizeString():N0}[/]").RightJustified(); + + return table; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseCommand.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseCommand.cs new file mode 100644 index 0000000..cc1c3de --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseCommand.cs @@ -0,0 +1,20 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseCommand : Command +{ + public static BrowseCommand Instance { get; } = new BrowseCommand(); + + private BrowseCommand() + : base(name: "browse", description: "(Experimental) Browse metadata of a tabular model in a VPAX package file") + { + AddCommand(BrowseModelCommand.Instance); + AddCommand(BrowseTableCommand.Instance); + AddCommand(BrowseColumnCommand.Instance); + AddCommand(BrowseRelationshipCommand.Instance); + + AddGlobalOption(Commands.CommonOptions.PathOption); + AddGlobalOption(CommonOptions.ExcludeHiddenOption); + AddGlobalOption(CommonOptions.OrderByOption); + AddGlobalOption(CommonOptions.TopOption); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommand.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommand.cs new file mode 100644 index 0000000..410f9e5 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommand.cs @@ -0,0 +1,13 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseModelCommand : Command +{ + public static BrowseModelCommand Instance { get; } = new BrowseModelCommand(); + + private BrowseModelCommand() + : base(name: "model", description: "Display tabular model information") + { + AddAlias("m"); + Handler = new BrowseModelCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommandHandler.cs new file mode 100644 index 0000000..86c879b --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseModelCommandHandler.cs @@ -0,0 +1,61 @@ +using Spectre.Console; + +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseModelCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var model = GetCurrentModel(context); + if (model is null) + return Task.FromResult(context.ExitCode); + + var grid = new Grid() + .AddColumns(1) + .AddRow(GetProperties(model)) + .AddEmptyRow() + .AddRow(GetSizeChart(model)); + + AnsiConsole.Write(new Panel(grid)); + return Task.FromResult(context.ExitCode); + } + + private IRenderable GetProperties(Metadata.Model model) + { + var table = new Spectre.Console.Table().HideHeaders().Expand().BorderColor(Color.Yellow) + .AddColumn("Name") + .AddColumn("Value") + .AddRow("[yellow]Model[/]", model.ModelName.Name) + .AddRow("[yellow]Compatibility Level[/]", model.CompatibilityLevel.ToString()) + .AddRow("[yellow]Compatibility Mode[/]", model.CompatibilityMode.ToEmptyIfNull()) + .AddRow("[yellow]Version[/]", model.Version.ToString()) + .AddRow("[yellow]Culture[/]", model.Culture.ToEmptyIfNull()) + .AddRow("[yellow]Last Refresh[/]", model.LastDataRefresh.ToString("o", CultureInfo.InvariantCulture)) + .AddRow("[yellow]Last Process[/]", model.LastProcessed.ToString("o", CultureInfo.InvariantCulture)) + .AddRow("[yellow]Last Update[/]", model.LastUpdate.ToString("o", CultureInfo.InvariantCulture)) + .AddRow("[yellow]Tables[/]", model.Tables.Count.ToString()) + .AddRow("[yellow]Columns[/]", model.Tables.SelectMany((t) => t.Columns).Where(c => !c.IsRowNumber).Count().ToString()) + .AddRow("[yellow]Size (in memory)[/]", model.Tables.Sum((t) => t.TableSize).ToSizeString()); + + return table; + } + + private IRenderable GetSizeChart(Metadata.Model model) + { + var dataSize = model.Tables.Sum((t) => t.ColumnsDataSize); + var dictionariesSize = model.Tables.Sum((t) => t.ColumnsDictionarySize); + var hierarchiesSize = model.Tables.Sum((t) => t.ColumnsHierarchiesSize); + var totalSize = dataSize + dictionariesSize + hierarchiesSize; + + var dataPercentage = Math.Floor((double)dataSize / totalSize * 100); + var dictionariePercentage = Math.Floor((double)dictionariesSize / totalSize * 100); + var hierarchiesPercentage = 100 - dataPercentage - dictionariePercentage; + + var chart = new BreakdownChart().ShowPercentage().FullSize() + .AddItem("Data", dataPercentage, Color.Red) + .AddItem("Dictionary", dictionariePercentage, Color.Green) + .AddItem("Hierarchy", hierarchiesPercentage, Color.Blue); + + return chart; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommand.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommand.cs new file mode 100644 index 0000000..4c62cd1 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommand.cs @@ -0,0 +1,15 @@ +using Dax.Vpax.CLI.Commands.Package; + +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseRelationshipCommand : Command +{ + public static BrowseRelationshipCommand Instance { get; } = new BrowseRelationshipCommand(); + + private BrowseRelationshipCommand() + : base(name: "relationship", description: "Display relationships information") + { + AddAlias("r"); + Handler = new BrowseRelationshipCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommandHandler.cs new file mode 100644 index 0000000..a937059 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseRelationshipCommandHandler.cs @@ -0,0 +1,64 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseRelationshipCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var model = GetCurrentModel(context); + if (model is null) + return Task.FromResult(context.ExitCode); + + AnsiConsole.Write(GetRelationshipView(context, model)); + return Task.FromResult(context.ExitCode); + } + + private IRenderable GetRelationshipView(InvocationContext context, Metadata.Model model) + { + var top = context.ParseResult.GetValueForOption(CommonOptions.TopOption); + var excludeHidden = context.ParseResult.GetValueForOption(CommonOptions.ExcludeHiddenOption); + var orderBy = context.ParseResult.GetValueForOption(CommonOptions.OrderByOption); + + var table = new Spectre.Console.Table().BorderColor(Color.Yellow) + .AddColumn(new TableColumn(new Markup("[yellow]Name[/]").Centered()).NoWrap()) + .AddColumn(new TableColumn(new Markup("[yellow]Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Max From Cardinality[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Max To Cardinality[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Missing Keys[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Invalid Rows[/]").Centered())); + + var query = orderBy switch + { + "name" or "n" => model.Relationships.OrderBy((r) => r.ToDisplayName()), + "cardinality" or "c" => model.Relationships.OrderByDescending((r) => r.FromColumn.ColumnCardinality), + "size" or "s" => model.Relationships.OrderByDescending((r) => r.UsedSize), + _ => model.Relationships.AsEnumerable() + }; + + if (excludeHidden) query = query.Where((r) => !r.IsHidden()); + if (top.HasValue) query = query.Take(top.Value); + + var modelSize = model.Tables.Sum((t) => t.TableSize); + var relationships = query.ToArray(); + + foreach (var r in relationships) + { + var style = r.IsHidden() ? new Style(foreground: Color.Grey) : Style.Plain; + + table.AddRow( + new Text(r.ToDisplayName(), style).LeftJustified(), + new Text(r.UsedSize.ToString("N0"), style).RightJustified(), + new Text(r.FromColumn.ColumnCardinality.ToString("N0"), style).RightJustified(), + new Text(r.ToColumn.ColumnCardinality.ToString("N0"), style).RightJustified(), + new Text(r.MissingKeys.ToString("N0"), style).RightJustified(), + new Text(r.InvalidRows.ToString("N0"), style).RightJustified() + ); + } + + table.Columns[0].Footer = new Markup($"[grey]{relationships.Length:N0} items[/]").LeftJustified(); + table.Columns[1].Footer = new Markup($"[grey]{relationships.Sum((r) => r.UsedSize).ToSizeString():N0}[/]").RightJustified(); + table.Columns[4].Footer = new Markup($"[grey]{relationships.Sum((r) => r.MissingKeys):N0}[/]").RightJustified(); + table.Columns[5].Footer = new Markup($"[grey]{relationships.Sum((r) => r.InvalidRows):N0}[/]").RightJustified(); + + return table; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommand.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommand.cs new file mode 100644 index 0000000..74149f8 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommand.cs @@ -0,0 +1,15 @@ +using Dax.Vpax.CLI.Commands.Package; + +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseTableCommand : Command +{ + public static BrowseTableCommand Instance { get; } = new BrowseTableCommand(); + + private BrowseTableCommand() + : base(name: "table", description: "Display table information") + { + AddAlias("t"); + Handler = new BrowseTableCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommandHandler.cs new file mode 100644 index 0000000..bf92774 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/BrowseTableCommandHandler.cs @@ -0,0 +1,75 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal sealed class BrowseTableCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var model = GetCurrentModel(context); + if (model is null) + return Task.FromResult(context.ExitCode); + + AnsiConsole.Write(GetTables(context, model)); + return Task.FromResult(context.ExitCode); + } + + private IRenderable GetTables(InvocationContext context, Metadata.Model model) + { + var top = context.ParseResult.GetValueForOption(CommonOptions.TopOption); + var excludeHidden = context.ParseResult.GetValueForOption(CommonOptions.ExcludeHiddenOption); + var orderBy = context.ParseResult.GetValueForOption(CommonOptions.OrderByOption); + + var table = new Spectre.Console.Table().BorderColor(Color.Yellow) + .AddColumn(new TableColumn(new Markup("[yellow]Name[/]").Centered()).NoWrap()) + .AddColumn(new TableColumn(new Markup("[yellow]Cardinality[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Size %[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Data Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Dictionary Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Hierarchies Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Columns[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Partitions[/]").Centered())); + + var query = orderBy switch + { + "name" or "n" => model.Tables.OrderBy((t) => t.TableName.Name), + "cardinality" or "c" => model.Tables.OrderByDescending((t) => t.RowsCount), + "size" or "s" => model.Tables.OrderByDescending((t) => t.TableSize), + _ => model.Tables.AsEnumerable() + }; + + if (excludeHidden) query = query.Where((t) => !t.IsHidden()); + if (top.HasValue) query = query.Take(top.Value); + + var modelSize = model.Tables.Sum((t) => t.TableSize); + var tables = query.ToArray(); + + foreach (var t in query) + { + var style = t.IsHidden() ? new Style(foreground: Color.Grey) : Style.Plain; + var sizePercentage = (double)t.TableSize / modelSize; + + table.AddRow( + new Text(t.TableName.Name, style).LeftJustified(), + new Text(t.RowsCount.ToString("N0"), style).RightJustified(), + new Text(t.TableSize.ToString("N0"), style).RightJustified(), + new Text(sizePercentage.ToString("P2"), style).RightJustified(), + new Text(t.ColumnsDataSize.ToString("N0"), style).RightJustified(), + new Text(t.ColumnsDictionarySize.ToString("N0"), style).RightJustified(), + new Text(t.ColumnsHierarchiesSize.ToString("N0"), style).RightJustified(), + new Text(t.Columns.Count.ToString("N0"), style).RightJustified(), + new Text(t.Partitions.Count.ToString("N0"), style).RightJustified() + ); + } + + table.Columns[0].Footer = new Markup($"[grey]{tables.Length:N0} items[/]").LeftJustified(); + table.Columns[1].Footer = new Markup($"[grey]{tables.Sum(_ => _.RowsCount):N0}[/]").RightJustified(); + table.Columns[2].Footer = new Markup($"[grey]{tables.Sum(_ => _.TableSize).ToSizeString()}[/]").RightJustified(); + table.Columns[4].Footer = new Markup($"[grey]{tables.Sum(_ => _.ColumnsDataSize).ToSizeString()}[/]").RightJustified(); + table.Columns[5].Footer = new Markup($"[grey]{tables.Sum(_ => _.ColumnsDictionarySize).ToSizeString()}[/]").RightJustified(); + table.Columns[6].Footer = new Markup($"[grey]{tables.Sum(_ => _.ColumnsHierarchiesSize).ToSizeString()}[/]").RightJustified(); + table.Columns[7].Footer = new Markup($"[grey]{tables.Sum(_ => _.Columns.Count):N0}[/]").RightJustified(); + table.Columns[8].Footer = new Markup($"[grey]{tables.Sum(_ => _.Partitions.Count):N0}[/]").RightJustified(); + + return table; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Browse/CommonOptions.cs b/src/Dax.Vpax.CLI/Commands/Browse/CommonOptions.cs new file mode 100644 index 0000000..293db67 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Browse/CommonOptions.cs @@ -0,0 +1,27 @@ +namespace Dax.Vpax.CLI.Commands.Browse; + +internal static class CommonOptions +{ + private static string[] OrderByColumns => new[] {"c", "cardinality", "n", "name", "s", "size" }; + + public static readonly Option ExcludeHiddenOption = new( + name: "--exclude-hidden", + description: "Specify whether to exclude hidden objects" + ); + + public static readonly Option OrderByOption = new( + name: "--orderby", + getDefaultValue: () => "size", + description: "Specify the column to sort by" + ); + + public static readonly Option TopOption = new( + name: "--top", + description: "Specify the maximum number of objects to display" + ); + + static CommonOptions() + { + OrderByOption.AddCompletions(OrderByColumns); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/CommandHandler.cs b/src/Dax.Vpax.CLI/Commands/CommandHandler.cs new file mode 100644 index 0000000..6c40491 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/CommandHandler.cs @@ -0,0 +1,48 @@ +namespace Dax.Vpax.CLI.Commands; + +internal abstract class CommandHandler : ICommandHandler +{ + public int Invoke(InvocationContext context) => throw new NotSupportedException("Use InvokeAsync instead."); + public abstract Task InvokeAsync(InvocationContext context); + + protected FileInfo? GetCurrentPackage(InvocationContext context) + { + var path = context.ParseResult.GetValueForOption(CommonOptions.PathOption) ?? UserSession.GetPackagePath(); + if (path is null) + { + AnsiConsole.MarkupLine($"[red]No package set. Use `vpax package set` to set a package.[/]"); + context.ExitCode = 2; + return null; + } + var file = new FileInfo(path); + if (!file.Exists) + { + AnsiConsole.MarkupLine($"[red]Package file does not exist or is not accessible. [[{path}]][/]"); + context.ExitCode = 3; + return null; + } + return file; + } + + protected Metadata.Model? GetCurrentModel(InvocationContext context) + { + var file = GetCurrentPackage(context); + if (file is null) + return null; + + var model = AnsiConsole.Status().AutoRefresh(true).Spinner(Spinner.Known.Default).Start("[yellow]Loading VPAX package...[/]", (context) => + { + var content = VpaxTools.ImportVpax(file.FullName, importDatabase: false); + return content.DaxModel; + }); + + if (model is null) + { + AnsiConsole.MarkupLine($"[red]Package does not contain {VpaxFormat.DAXMODEL}. Verify the package and try again.[/]"); + context.ExitCode = 3; + return null; + } + + return model; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/CommonOptions.cs b/src/Dax.Vpax.CLI/Commands/CommonOptions.cs new file mode 100644 index 0000000..6429754 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/CommonOptions.cs @@ -0,0 +1,9 @@ +namespace Dax.Vpax.CLI.Commands; + +internal static class CommonOptions +{ + public static readonly Option PathOption = new( + name: "--path", + description: "Path to the VPAX package file. You can configure the default package using `vpax package set `" + ); +} diff --git a/src/Dax.Vpax.CLI/Commands/Export/ExportCommand.cs b/src/Dax.Vpax.CLI/Commands/Export/ExportCommand.cs new file mode 100644 index 0000000..1b4cc99 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Export/ExportCommand.cs @@ -0,0 +1,23 @@ +using static Dax.Vpax.CLI.Commands.Export.ExportCommandOptions; + +namespace Dax.Vpax.CLI.Commands.Export; + +internal sealed class ExportCommand : Command +{ + public static ExportCommand Instance { get; } = new ExportCommand(); + + private ExportCommand() + : base(name: "export", description: "Export a VPAX file from a tabular model") + { + AddArgument(PathArgument); + AddArgument(ConnectionStringArgument); + AddOption(OverwriteOption); + AddOption(ExcludeTomOption); + AddOption(ExcludeVpaOption); + AddOption(DirectQueryModeOption); + AddOption(DirectLakeModeOption); + AddOption(ColumnBatchSizeOption); + + Handler = new ExportCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/ExportCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Export/ExportCommandHandler.cs similarity index 83% rename from src/Dax.Vpax.CLI/Commands/ExportCommandHandler.cs rename to src/Dax.Vpax.CLI/Commands/Export/ExportCommandHandler.cs index f28177e..ab7c177 100644 --- a/src/Dax.Vpax.CLI/Commands/ExportCommandHandler.cs +++ b/src/Dax.Vpax.CLI/Commands/Export/ExportCommandHandler.cs @@ -1,18 +1,10 @@ -using System.CommandLine; -using System.CommandLine.Invocation; -using System.CommandLine.IO; -using Dax.Metadata; -using Dax.Model.Extractor; -using Dax.Vpax.Tools; -using static Dax.Vpax.CLI.Commands.ExportCommandOptions; +using static Dax.Vpax.CLI.Commands.Export.ExportCommandOptions; -namespace Dax.Vpax.CLI.Commands; +namespace Dax.Vpax.CLI.Commands.Export; -internal sealed class ExportCommandHandler : ICommandHandler +internal sealed class ExportCommandHandler : CommandHandler { - public int Invoke(InvocationContext context) => throw new NotSupportedException("Use InvokeAsync instead."); - - public async Task InvokeAsync(InvocationContext context) + public override async Task InvokeAsync(InvocationContext context) { // TODO: forward cancellation token to vertipaq-analyzer extractor var cancellationToken = context.GetCancellationToken(); diff --git a/src/Dax.Vpax.CLI/Commands/ExportCommandOptions.cs b/src/Dax.Vpax.CLI/Commands/Export/ExportCommandOptions.cs similarity index 94% rename from src/Dax.Vpax.CLI/Commands/ExportCommandOptions.cs rename to src/Dax.Vpax.CLI/Commands/Export/ExportCommandOptions.cs index aed13fd..24f0d09 100644 --- a/src/Dax.Vpax.CLI/Commands/ExportCommandOptions.cs +++ b/src/Dax.Vpax.CLI/Commands/Export/ExportCommandOptions.cs @@ -1,9 +1,4 @@ -using Dax.Metadata; -using Dax.Model.Extractor; -using System.CommandLine; -using System.Data.Common; - -namespace Dax.Vpax.CLI.Commands; +namespace Dax.Vpax.CLI.Commands.Export; internal static class ExportCommandOptions { diff --git a/src/Dax.Vpax.CLI/Commands/ExportCommand.cs b/src/Dax.Vpax.CLI/Commands/ExportCommand.cs deleted file mode 100644 index bf1f718..0000000 --- a/src/Dax.Vpax.CLI/Commands/ExportCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.CommandLine; -using static Dax.Vpax.CLI.Commands.ExportCommandOptions; - -namespace Dax.Vpax.CLI.Commands; - -internal static class ExportCommand -{ - private static readonly ExportCommandHandler s_handler = new(); - - internal static Command GetCommand() - { - var command = new Command("export", "Export a VPAX file from a tabular model") - { - PathArgument, - ConnectionStringArgument, - OverwriteOption, - // advanced options - ExcludeTomOption, - ExcludeVpaOption, - DirectQueryModeOption, - DirectLakeModeOption, - ColumnBatchSizeOption, - }; - command.Handler = s_handler; - return command; - } -} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageCommand.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageCommand.cs new file mode 100644 index 0000000..d1331a2 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageCommand.cs @@ -0,0 +1,15 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageCommand : Command +{ + public static PackageCommand Instance { get; } = new PackageCommand(); + + private PackageCommand() + : base(name: "package", description: "(Experimental) Manage a VPAX package file") + { + AddCommand(PackageExtractCommand.Instance); + AddCommand(PackageSetCommand.Instance); + AddCommand(PackageShowCommand.Instance); + AddCommand(PackageUnsetCommand.Instance); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommand.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommand.cs new file mode 100644 index 0000000..4e56af4 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommand.cs @@ -0,0 +1,29 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageExtractCommand : Command +{ + public static PackageExtractCommand Instance { get; } = new PackageExtractCommand(); + + private PackageExtractCommand() + : base(name: "extract", description: "Extract all files from a VPAX package") + { + AddArgument(PackageExtractCommandOptions.PathArgument); + AddOption(Commands.CommonOptions.PathOption); + AddOption(PackageExtractCommandOptions.OverwriteOption); + + Handler = new PackageExtractCommandHandler(); + } +} +internal static class PackageExtractCommandOptions +{ + public static readonly Argument PathArgument = new( + name: "path", + description: "Path to write the extracted files" + ); + + public static readonly Option OverwriteOption = new( + name: "--overwrite", + getDefaultValue: () => false, + description: "Overwrite the extracted file if it already exists" + ); +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommandHandler.cs new file mode 100644 index 0000000..9afb9a6 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageExtractCommandHandler.cs @@ -0,0 +1,36 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageExtractCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var file = GetCurrentPackage(context); + if (file is null) + return Task.FromResult(context.ExitCode); + + using var package = System.IO.Packaging.Package.Open(file.FullName, FileMode.Open, FileAccess.Read); + + var overwrite = context.ParseResult.GetValueForOption(PackageExtractCommandOptions.OverwriteOption); + var path = context.ParseResult.GetValueForArgument(PackageExtractCommandOptions.PathArgument); + _ = Directory.CreateDirectory(path.FullName); + + AnsiConsole.Status().AutoRefresh(true).Spinner(Spinner.Known.Default).Start($"[yellow]Extracting package {Markup.Escape(file.FullName)}...[/]", (context) => + { + foreach (var part in package.GetParts()) + { + var partName = part.Uri.OriginalString.TrimStart('/'); + AnsiConsole.MarkupLine($"[grey]Extracting {Markup.Escape(partName)}...[/]"); + + var filePath = Path.Combine(path.FullName, partName); + var fileMode = overwrite ? FileMode.Create : FileMode.CreateNew; + using var fileStream = new FileStream(filePath, fileMode, FileAccess.Write); + using var partStream = part.GetStream(); + partStream.CopyTo(fileStream); + } + + //AnsiConsole.MarkupLine("[green]Completed[/]"); + }); + + return Task.FromResult(context.ExitCode); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommand.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommand.cs new file mode 100644 index 0000000..b27b885 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommand.cs @@ -0,0 +1,22 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageSetCommand : Command +{ + public static PackageSetCommand Instance { get; } = new PackageSetCommand(); + + private PackageSetCommand() + : base(name: "set", description: "Set a VPAX file to be the current active package") + { + AddArgument(PackageSetCommandOptions.PathArgument); + + Handler = new PackageSetCommandHandler(); + } +} + +internal static class PackageSetCommandOptions +{ + public static readonly Argument PathArgument = new( + name: "path", + description: "Path to the VPAX package file." + ); +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommandHandler.cs new file mode 100644 index 0000000..b40aaab --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageSetCommandHandler.cs @@ -0,0 +1,23 @@ +using static Dax.Vpax.CLI.Commands.Package.PackageSetCommandOptions; + +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageSetCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var path = context.ParseResult.GetValueForArgument(PathArgument); + + if (!File.Exists(path)) + { + AnsiConsole.MarkupLine($"[red]The package file does not exist or is not accessible. [[{path}]][/]"); + return Task.FromResult(context.ExitCode = 1); + } + + var session = UserSession.Load(); + session.Package.Path = path; + session.Save(); + + return Task.FromResult(context.ExitCode); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommand.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommand.cs new file mode 100644 index 0000000..a89f34a --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommand.cs @@ -0,0 +1,14 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageShowCommand : Command +{ + public static PackageShowCommand Instance { get; } = new PackageShowCommand(); + + private PackageShowCommand() + : base(name: "show", description: "Show details of a VPAX package file") + { + AddOption(Commands.CommonOptions.PathOption); + + Handler = new PackageShowCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommandHandler.cs new file mode 100644 index 0000000..679318f --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageShowCommandHandler.cs @@ -0,0 +1,68 @@ +using Spectre.Console; + +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageShowCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var file = GetCurrentPackage(context); + if (file is null) + return Task.FromResult(context.ExitCode); + + var grid = new Grid() + .AddColumns(1) + .AddRow(GetProperties(file)) + .AddEmptyRow() + .AddRow(GetContent(file)); + + AnsiConsole.Write(new Panel(grid)); + return Task.FromResult(context.ExitCode); + } + + private IRenderable GetProperties(FileInfo file) + { + var table = new Spectre.Console.Table().HideHeaders().Expand().BorderColor(Color.Yellow) + .AddColumn("Name") + .AddColumn("Value"); + table.AddRow("[yellow]File[/]", file.FullName); + table.AddRow("[yellow]Size[/]", file.Length.ToSizeString()); + table.AddRow("[yellow]Created[/]", file.CreationTime.ToString("o", CultureInfo.InvariantCulture)); + table.AddRow("[yellow]Modified[/]", file.LastWriteTime.ToString("o", CultureInfo.InvariantCulture)); + table.AddRow("[yellow]Accessed[/]", file.LastAccessTime.ToString("o", CultureInfo.InvariantCulture)); + + return table; + } + + private IRenderable GetContent(FileInfo file) + { + var table = new Spectre.Console.Table().HeavyBorder().Expand().BorderColor(Color.Yellow) + .AddColumn(new TableColumn(new Markup("[yellow]Part[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Size[/]").Centered())) + .AddColumn(new TableColumn(new Markup("[yellow]Type[/]").Centered())); + + using var package = System.IO.Packaging.Package.Open(file.FullName, FileMode.Open, FileAccess.Read); + var totalSize = 0L; + var partsCount = 0; + + foreach (var part in package.GetParts()) + { + using var stream = part.GetStream(); + var size = stream.Length; + + totalSize += size; + partsCount++; + + table.AddRow( + new Text(part.Uri.OriginalString).LeftJustified(), + new Text(size.ToSizeString()).RightJustified(), + new Text(part.ContentType).Centered() + ); + } + + table.Columns[0].Footer($"[grey]{partsCount} items[/]"); + table.Columns[1].Footer = new Markup($"[grey]{totalSize.ToSizeString()}[/]").RightJustified(); + + return table; + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommand.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommand.cs new file mode 100644 index 0000000..8b0f5ca --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommand.cs @@ -0,0 +1,12 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageUnsetCommand : Command +{ + public static PackageUnsetCommand Instance { get; } = new PackageUnsetCommand(); + + private PackageUnsetCommand() + : base(name: "unset", description: "Unset the current VPAX package file") + { + Handler = new PackageUnsetCommandHandler(); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommandHandler.cs b/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommandHandler.cs new file mode 100644 index 0000000..5e23b64 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/Package/PackageUnsetCommandHandler.cs @@ -0,0 +1,13 @@ +namespace Dax.Vpax.CLI.Commands.Package; + +internal sealed class PackageUnsetCommandHandler : CommandHandler +{ + public override Task InvokeAsync(InvocationContext context) + { + var session = UserSession.Load(); + session.Package.Path = null; + session.Save(); + + return Task.FromResult(context.ExitCode); + } +} diff --git a/src/Dax.Vpax.CLI/Commands/RootVpaxCommand.cs b/src/Dax.Vpax.CLI/Commands/RootVpaxCommand.cs new file mode 100644 index 0000000..db1de40 --- /dev/null +++ b/src/Dax.Vpax.CLI/Commands/RootVpaxCommand.cs @@ -0,0 +1,14 @@ +namespace Dax.Vpax.CLI.Commands; + +internal sealed class RootVpaxCommand : RootCommand +{ + public RootVpaxCommand() + : base(description: "VertiPaq-Analyzer CLI") + { + Name = "vpax"; // Name must match in csproj + + AddCommand(Browse.BrowseCommand.Instance); + AddCommand(Export.ExportCommand.Instance); + AddCommand(Package.PackageCommand.Instance); + } +} diff --git a/src/Dax.Vpax.CLI/Dax.Vpax.CLI.csproj b/src/Dax.Vpax.CLI/Dax.Vpax.CLI.csproj index 168d099..09cf395 100644 --- a/src/Dax.Vpax.CLI/Dax.Vpax.CLI.csproj +++ b/src/Dax.Vpax.CLI/Dax.Vpax.CLI.csproj @@ -1,11 +1,10 @@ - + net6.0;net8.0 Exe enable - 10.0 - enable + 12.0 all latest en-US @@ -27,7 +26,9 @@ + + diff --git a/src/Dax.Vpax.CLI/Extensions/CommandLineBuilderExtensions.cs b/src/Dax.Vpax.CLI/Extensions/CommandLineBuilderExtensions.cs new file mode 100644 index 0000000..2133d9b --- /dev/null +++ b/src/Dax.Vpax.CLI/Extensions/CommandLineBuilderExtensions.cs @@ -0,0 +1,41 @@ +using System.CommandLine.Builder; +using System.CommandLine.Help; +using Dax.Vpax.CLI.Commands.Export; + +namespace Dax.Vpax.CLI.Extensions; + +internal static class CommandLineBuilderExtensions +{ + public static CommandLineBuilder UseVpaxDefaults(this CommandLineBuilder builder) + { + return builder.UseVersionOption().UseParseErrorReporting().CancelOnProcessTermination().UseCustomHelp(); + //.UseEnvironmentVariableDirective().UseParseDirective().UseSuggestDirective().RegisterWithDotnetSuggest().UseTypoCorrections().UseExceptionHandler() + } + + private static CommandLineBuilder UseCustomHelp(this CommandLineBuilder builder) + { + return builder.UseHelp((context) => + { + if (context.Command is RootVpaxCommand) + { + // Removes the arguments from the output help + var subcommand = context.Command.Children.OfType().Single(); + context.HelpBuilder.CustomizeSymbol(subcommand, firstColumnText: subcommand.Name); + } + + //if (context.Command.Handler is null) + //{ + // foreach (var child in context.Command.Children) + // { + // var isExperimental = + // child is Commands.Browse.BrowseCommand || + // child is Commands.Package.PackageCommand; + + // // Removes the arguments from the output help for all commands, adds [Experimental] to the command name if applicable + // var text = isExperimental ? $"{child.Name} [Experimental]" : child.Name; + // context.HelpBuilder.CustomizeSymbol(child, firstColumnText: text); + // } + //} + }); + } +} diff --git a/src/Dax.Vpax.CLI/Extensions/CommonExtensions.cs b/src/Dax.Vpax.CLI/Extensions/CommonExtensions.cs new file mode 100644 index 0000000..c0607bb --- /dev/null +++ b/src/Dax.Vpax.CLI/Extensions/CommonExtensions.cs @@ -0,0 +1,50 @@ +namespace Dax.Vpax.CLI.Extensions; + +internal static class CommonExtensions +{ + public static string ToEmptyIfNull(this string? value) + { + return value ?? string.Empty; + } + + public static string ToSizeString(this long size) + { + const double KB = 1024; + const double MB = KB * 1024; + const double GB = MB * 1024; + + if (size < KB) return $"{size} B"; + if (size < MB) return $"{size / KB:F2} KB"; + if (size < GB) return $"{size / MB:F2} MB"; + + return $"{size / GB:F2} GB"; + } + + public static bool IsHidden(this Dax.Metadata.Relationship relationship) + { + return relationship.FromColumn.Table.IsHidden() || relationship.ToColumn.Table.IsHidden(); + } + + public static bool IsHidden(this Dax.Metadata.Table table) + { + return table.IsHidden || table.IsPrivate; + } + + public static string ToDisplayName(this Dax.Metadata.Column column) + { + return $"{column.Table.TableName.Name}[{column.ColumnName.Name}]"; + } + + public static string ToDisplayName(this Dax.Metadata.Relationship relationship) + { + var from = relationship.FromColumn.ToDisplayName(); + var to = relationship.ToColumn.ToDisplayName(); + + // Code copied from Dax.ViewModel.VpaRelationship.RelationshipFromToName + var p1 = relationship.FromCardinalityType == null ? "<" : relationship.FromCardinalityType == "Many" ? "*" : "1"; + var p2 = relationship.CrossFilteringBehavior == null ? "=" : relationship.CrossFilteringBehavior == "BothDirections" ? "<>" : "<"; + var p3 = relationship.ToCardinalityType == null ? "=" : relationship.ToCardinalityType == "Many" ? "*" : "1"; + + return $"{from} {p1}{p2}{p3} {to}"; + } +} diff --git a/src/Dax.Vpax.CLI/GlobalUsings.cs b/src/Dax.Vpax.CLI/GlobalUsings.cs new file mode 100644 index 0000000..0d6bd22 --- /dev/null +++ b/src/Dax.Vpax.CLI/GlobalUsings.cs @@ -0,0 +1,18 @@ +global using Dax.Metadata; +global using Dax.Model.Extractor; +global using Dax.Vpax.CLI.Commands; +global using Dax.Vpax.CLI.Extensions; +global using Dax.Vpax.Tools; +global using Spectre.Console; +global using Spectre.Console.Rendering; +global using System; +global using System.Collections.Generic; +global using System.CommandLine; +global using System.CommandLine.Invocation; +global using System.CommandLine.IO; +global using System.Data.Common; +global using System.Globalization; +global using System.IO; +global using System.Linq; +global using System.Text.Json; +global using System.Threading.Tasks; diff --git a/src/Dax.Vpax.CLI/Program.cs b/src/Dax.Vpax.CLI/Program.cs index 4768b6a..ce145bd 100644 --- a/src/Dax.Vpax.CLI/Program.cs +++ b/src/Dax.Vpax.CLI/Program.cs @@ -1,5 +1,5 @@ -using System.CommandLine; -using Dax.Vpax.CLI.Commands; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; namespace Dax.Vpax.CLI; @@ -8,11 +8,12 @@ internal sealed class Program public static async Task Main(string[] args) => await Build().InvokeAsync(args).ConfigureAwait(false); - private static RootCommand Build() + private static Parser Build() { - var command = new RootCommand("VertiPaq-Analyzer CLI"); - command.Name = "vpax"; // Name must match in csproj - command.AddCommand(ExportCommand.GetCommand()); - return command; + var command = new RootVpaxCommand(); + var builder = new CommandLineBuilder(command); + _ = builder.UseVpaxDefaults(); + + return builder.Build(); } } diff --git a/src/Dax.Vpax.CLI/Properties/launchSettings.json b/src/Dax.Vpax.CLI/Properties/launchSettings.json index caa9731..e6cba39 100644 --- a/src/Dax.Vpax.CLI/Properties/launchSettings.json +++ b/src/Dax.Vpax.CLI/Properties/launchSettings.json @@ -1,7 +1,8 @@ { "profiles": { "Dax.Vpax.CLI": { - "commandName": "Project" + "commandName": "Project", + "commandLineArgs": "" } } } diff --git a/src/Dax.Vpax.CLI/README.md b/src/Dax.Vpax.CLI/README.md index d785e5c..db71525 100644 --- a/src/Dax.Vpax.CLI/README.md +++ b/src/Dax.Vpax.CLI/README.md @@ -2,9 +2,11 @@ This is a .NET tool that provides CLI access to [VertiPaq-Analyzer](https://github.com/sql-bi/VertiPaq-Analyzer) functions. -Operations supported by this tool are: +Commands supported by this tool are: -- Export a VPAX file from a tabular model. +- `export` to export a VPAX file from a tabular model +- `browse` to browse metadata of a tabular model in a VPAX package file __[Experimental]__ +- `package` to manage a VPAX package file __[Experimental]__ ## How to install the tool diff --git a/src/Dax.Vpax.CLI/UserSession.cs b/src/Dax.Vpax.CLI/UserSession.cs new file mode 100644 index 0000000..f03023b --- /dev/null +++ b/src/Dax.Vpax.CLI/UserSession.cs @@ -0,0 +1,35 @@ +namespace Dax.Vpax.CLI; + +internal sealed class UserSession +{ + private static readonly string FilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".vpax", "cli-session.json"); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static UserSession Load() + { + UserSession? session = null; + + if (File.Exists(FilePath)) + session = JsonSerializer.Deserialize(json: File.ReadAllText(FilePath), JsonOptions); + + return session ?? new UserSession(); + } + + public static string? GetPackagePath() => Load().Package.Path; + + public Package Package { get; init; } = new(); + + public void Save() + { + _ = Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions)); + } +} + +internal sealed class Package +{ + public string? Path { get; set; } +}