This repository has been archived on 2023-02-02. You can view files and clone it, but cannot push or open issues or pull requests.
mfgames-toolbuilder-cil/src/MfGames.ToolBuilder.Tables/TableToolService.cs

463 lines
15 KiB
C#

#pragma warning disable Serilog004 // Constant MessageTemplate verifier
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.IO;
using System.Data;
using System.Globalization;
using ConsoleTableExt;
using CsvHelper;
using CsvHelper.Configuration;
using FluentResults;
using GlobExpressions;
using MfGames.ToolBuilder.Extensions;
using Newtonsoft.Json;
using Serilog;
namespace MfGames.ToolBuilder.Tables
{
/// <summary>
/// Contains various utility functions for working with commands that display
/// tables.
/// </summary>
public class TableToolService
{
private readonly Command command;
private readonly ILogger logger;
private readonly Option<bool> noAlignTableOption;
private readonly Option<string> tableColumnOption;
private readonly Option<string> tableFormatOption;
private DataTable? table;
/// <summary>
/// Creates a new table CLI service with specific commands.
/// </summary>
/// <param name="logger">The logger for requests.</param>
/// <param name="command">The command to add the options to.</param>
/// <param name="table">The optional table to columns the parameters.</param>
/// <param name="defaultColumns">A list of columns to display if none are set.</param>
public TableToolService(
ILogger logger,
Command command,
DataTable? table = null,
IList<string>? defaultColumns = null)
{
// Set member variables.
this.logger = logger;
this.command = command;
this.table = table;
this.DefaultColumns = defaultColumns;
// If we have a table, then we can show the list of known columns.
string? tableColumnsDescription =
"The columns to display in the output.";
string? tableFormatDescription = string.Format(
"The fuzzy format of the table, one of: {0}.",
string.Join(", ", Enum.GetNames<TableFormatType>()));
if (table != null)
{
tableColumnsDescription = string.Format(
"The columns to display in the output, \"*\" or comma-separated list of: {0}.",
string.Join(
", ",
table.Columns.OfType<DataColumn>()
.Select(x => x.ColumnName)));
if (defaultColumns != null)
{
tableColumnsDescription += " [default: "
+ string.Join(",", defaultColumns)
+ "]";
}
}
// Create the parameters for the table.
this.tableColumnOption = new Option<string>(
"--table-columns",
tableColumnsDescription)
{
ArgumentHelpName = "column[,column...]",
};
this.noAlignTableOption = new Option<bool>(
"--no-align-table-columns",
() => false,
"If set, don't right-align numerical columns.");
this.tableFormatOption = new Option<string>(
"--table-format",
() => nameof(TableFormatType.Default),
tableFormatDescription)
{
ArgumentHelpName = "format",
};
// Add the options into the command.
command.AddOption(this.tableFormatOption);
command.AddOption(this.noAlignTableOption);
command.AddOption(this.tableColumnOption);
}
public delegate TableToolService Factory(
Command command,
DataTable? table = null,
IList<string>? defaultColumns = null);
public IList<string>? DefaultColumns { get; set; }
public IList<string> GetVisibleColumnNames(InvocationContext context)
{
// Do a little sanity checking on member variables.
if (this.table == null)
{
throw new NullReferenceException(
"The table field has not been defined.");
}
// Figure out the columns that the user wants to see. If they have
// not specified any, then we use the defaults.
List<string> defaultColumns =
this.DefaultColumns?.ToList() ?? new List<string>();
var optionColumns = context.ParseResult
.GetValueListForOption(this.tableColumnOption)
.ToList();
List<string> tableColumns = optionColumns.Count == 0
? defaultColumns
: optionColumns;
// Expand the columns out to handle a simplified globbing where "*"
// means include all columns.
var allColumns = this.table.Columns
.Cast<DataColumn>()
.Select(x => x.ColumnName)
.ToList();
var expandedNames = tableColumns
.SelectMany(x => IsMatch(allColumns, x))
.ToList();
// Do a fuzzy matching of names. This allows for a case-insensitive
// search while also allowing for prefixes (so "d" works for
// "Debug").
List<Result<string>> fuzzyResults = expandedNames
.ConvertAll(x => x.GetFuzzy(allColumns));
ToolException.ThrowIf(fuzzyResults);
var fuzzyValues = fuzzyResults
.Select(x => x.Value)
.Distinct()
.ToList();
return fuzzyValues;
}
/// <summary>
/// Determines if the column is going to be shown to the user.
/// </summary>
/// <param name="context">The context of the request.</param>
/// <param name="columnName">The column to query.</param>
/// <returns>True if the column is visible, otherwise false.</returns>
public bool IsVisible(InvocationContext context, string columnName)
{
if (this.table == null)
{
throw new InvalidOperationException(
"Cannot use IsVisible without a sample table given.");
}
IList<string> visibleColumns = this.GetVisibleColumnNames(context);
bool visible = visibleColumns
.Any(
x => x.Equals(
columnName,
StringComparison.InvariantCultureIgnoreCase));
return visible;
}
/// <summary>
/// Renders out the table using the requested formats.
/// </summary>
/// <param name="context">The context of the command.</param>
/// <param name="updatedTable">The table to use if not the initial one..</param>
public void Write(
InvocationContext context,
DataTable? updatedTable = null)
{
// Get the table and make sure we have it.
this.table = updatedTable ?? this.table;
if (this.table == null)
{
throw new InvalidOperationException(
"Cannot write out a table if not given in the constructor, updated via property, or passed into the write method.");
}
// Adjust, hide, and reorder columns.
if (!this.AdjustColumns(context))
{
throw new ToolException(
"Cannot adjust the columns of the resulting data");
}
// Pass the resulting data to the formatting code.
TableFormatType tableFormat = this.GetTableFormat(context);
switch (tableFormat)
{
case TableFormatType.Default:
case TableFormatType.Markdown:
case TableFormatType.Alternative:
case TableFormatType.Minimal:
this.WriteTable(context, tableFormat);
break;
case TableFormatType.Json:
this.WriteJson(context);
break;
case TableFormatType.List:
this.WriteList(context);
break;
case TableFormatType.Csv:
this.WriteCsv(context);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
private static IEnumerable<string> IsMatch(
IEnumerable<string> columns,
string input)
{
var glob = new Glob(input, GlobOptions.CaseInsensitive);
var matches = columns
.Where(x => glob.IsMatch(x))
.ToList();
if (matches.Count > 0)
{
return matches;
}
return new[] { input };
}
private bool AdjustColumns(InvocationContext context)
{
// Figure out if we need to change the columns.
IList<string> columnNames = this.GetVisibleColumnNames(context);
if (columnNames.Count == 0)
{
this.logger.Error("There were no columns to display");
return false;
}
// Remove excessive columns.
IEnumerable<DataColumn> columnsToRemove = this.table!.Columns
.OfType<DataColumn>()
.Where(x => !columnNames.Contains(x.ColumnName))
.ToList();
foreach (DataColumn column in columnsToRemove)
{
this.table.Columns.Remove(column);
}
// Reorder the columns to match requested order.
for (int order = 0; order < columnNames.Count; order++)
{
this.table.Columns[columnNames[order]]!.SetOrdinal(order);
}
return true;
}
private TableFormatType GetTableFormat(InvocationContext context)
{
string? tableFormat =
context.ParseResult.GetValueForOption(this.tableFormatOption)
?? nameof(TableFormatType.Minimal);
if (!tableFormat.TryParseEnumFuzzy(out TableFormatType value))
{
throw new InvalidOperationException(
"Unknown table format '"
+ tableFormat
+ "'. Must be one of: "
+ string.Join(", ", Enum.GetNames<TableFormatType>()));
}
return value;
}
/// <summary>
/// Writes out the contents of the table as CSV.
/// </summary>
private void WriteCsv(InvocationContext context)
{
var configuration =
new CsvConfiguration(CultureInfo.CurrentCulture);
var stringWriter = new StringWriter();
var csv = new CsvWriter(stringWriter, configuration);
foreach (DataColumn column in
this.table!.Columns.OfType<DataColumn>())
{
csv.WriteField(column.ColumnName);
}
csv.NextRecord();
foreach (DataRow row in this.table.AsEnumerable())
{
foreach (DataColumn column in this.table.Columns
.OfType<DataColumn>())
{
csv.WriteField(Convert.ToString(row[column]));
}
csv.NextRecord();
}
context.Console.Out.Write(stringWriter.ToString());
}
/// <summary>
/// Writes out the contents of the table as JSON.
/// </summary>
private void WriteJson(InvocationContext context)
{
context.Console.Out.WriteLine(
JsonConvert.SerializeObject(this.table));
}
/// <summary>
/// Writes out the contents of the table in a PowerShell-like list.
/// </summary>
private void WriteList(InvocationContext context)
{
int labelWidth = this.table!.Columns
.OfType<DataColumn>()
.Max(x => x.ColumnName.Length);
bool first = true;
foreach (DataRow row in this.table.AsEnumerable())
{
if (first)
{
first = false;
}
else
{
context.Console.Out.WriteLine();
}
for (int i = 0; i < this.table.Columns.Count; i++)
{
context.Console.Out.WriteLine(
string.Format(
"{0} : {1}",
this.table.Columns[i]
.ColumnName.PadRight(labelWidth),
row[i]));
}
}
}
private void WriteTable(
InvocationContext context,
TableFormatType tableFormat)
{
// Build the table from options.
var builder = ConsoleTableBuilder.From(this.table);
// Figure out formatting.
if (tableFormat == TableFormatType.Default)
{
// Default is mostly like ConsoleTableExt but there is a separator
// between the headers because it makes it harder to parse.
builder
.WithPaddingLeft(string.Empty)
.WithPaddingRight(string.Empty)
.WithCharMapDefinition(
new Dictionary<CharMapPositions, char>
{
{ CharMapPositions.DividerY, ' ' },
{ CharMapPositions.BottomCenter, ' ' },
})
.WithHeaderCharMapDefinition(
new Dictionary<HeaderCharMapPositions, char>
{
{ HeaderCharMapPositions.BottomCenter, '+' },
{ HeaderCharMapPositions.Divider, ' ' },
{ HeaderCharMapPositions.BorderBottom, '-' },
});
}
else
{
builder.WithFormat((ConsoleTableBuilderFormat)tableFormat);
}
// We default to aligning numerical columns to the right.
bool noAlign =
context.ParseResult.GetValueForOption(this.noAlignTableOption);
if (!noAlign)
{
builder.WithTextAlignment(
this.table!.Columns
.OfType<DataColumn>()
.Select(
(column, index) => (Index: index,
Numeric: column.DataType.Name switch
{
nameof(Byte) => true,
nameof(Int16) => true,
nameof(Int32) => true,
nameof(Int64) => true,
nameof(Decimal) => true,
_ => false,
}))
.Where(x => x.Numeric)
.ToDictionary(x => x.Index, x => TextAligntment.Right));
}
// Write out the results.
string rendered = builder.Export()
.ToString()
.TrimEnd(' ', '\n', '\r', '\0');
context.Console.Out.WriteLine(rendered);
}
}
}