diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 211d51e..f417bfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: run: dotnet restore .\src\Adomd.Cli\Adomd.Cli.csproj - name: Publish win-x64 executable - run: dotnet publish .\src\Adomd.Cli\Adomd.Cli.csproj --configuration Release --runtime win-x64 --self-contained true --output .\artifacts\publish\win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:Version=${{ env.PACKAGE_VERSION }} -p:PackageVersion=${{ env.PACKAGE_VERSION }} + run: dotnet publish .\src\Adomd.Cli\Adomd.Cli.csproj --configuration Release --runtime win-x64 --self-contained true --output .\artifacts\publish\win-x64 -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:Version=${{ env.PACKAGE_VERSION }} -p:PackageVersion=${{ env.PACKAGE_VERSION }} - name: Archive win-x64 executable shell: pwsh @@ -58,6 +58,21 @@ jobs: $hash = Get-FileHash -Path .\artifacts\adomd-cli-win-x64.zip -Algorithm SHA256 "$($hash.Hash.ToLowerInvariant()) adomd-cli-win-x64.zip" | Out-File -FilePath .\artifacts\adomd-cli-win-x64.zip.sha256 -Encoding ascii -NoNewline + - name: Extract release notes + shell: pwsh + run: | + $version = $env:PACKAGE_VERSION + $changelog = Get-Content .\CHANGELOG.md -Raw + # Grab the body of the section for this version, up to the next "## [" heading. + $pattern = "(?ms)^##\s*\[$([regex]::Escape($version))\].*?$("`n")(.*?)(?=^##\s*\[|\z)" + $match = [regex]::Match($changelog, $pattern) + if ($match.Success) { + $notes = $match.Groups[1].Value.Trim() + } else { + $notes = "Release v$version" + } + Set-Content -Path .\artifacts\release-notes.md -Value $notes -Encoding utf8 + - name: Upload executable artifact uses: actions/upload-artifact@v7 with: @@ -72,4 +87,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create $env:GITHUB_REF_NAME ./artifacts/adomd-cli-win-x64.zip ./artifacts/adomd-cli-win-x64.zip.sha256 --title $env:GITHUB_REF_NAME --notes "Release $env:GITHUB_REF_NAME" --verify-tag + gh release create $env:GITHUB_REF_NAME ./artifacts/adomd-cli-win-x64.zip ./artifacts/adomd-cli-win-x64.zip.sha256 --title $env:GITHUB_REF_NAME --notes-file ./artifacts/release-notes.md --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 84da702..a533675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project are documented in this file. +## [0.2.1] + +### Added +- `--compact` option on all commands to emit single-line (unindented) JSON for piping into line-based tools. +- `schema` now reports a top-level `partial` flag and a `warnings` array when one or more schema rowsets fail, instead of only signalling failure inside individual rows. + +### Changed +- UTF-8 is now forced for console output so non-ASCII catalog, dimension, and measure names/values are emitted and parsed correctly regardless of the host code page. +- When a `schema` rowset fails, the error is now reported via top-level `warnings` and per-rowset `error`/`exception` fields rather than as a synthetic data row inside `rows`. +- Release builds now use ReadyToRun for faster startup, and the GitHub Release notes are populated from the matching `CHANGELOG.md` section. + +### Fixed +- Corrected a stale hard-coded version fallback used when the assembly informational version attribute is unavailable. + ## [0.2.0] ### Added diff --git a/README.md b/README.md index e2ae5f1..0590846 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ dotnet run --project src\Adomd.Cli -- query --connection-string "` | Number of times to retry opening the connection after a transient failure; default `0` | | `--retry-delay-ms ` | Delay between connection retry attempts; default `1000` | | `--rowset ` | `schema` only, repeatable; fetch additional schema rowsets by GUID beyond the built-in six | +| `--compact` | Emit single-line (unindented) JSON, friendlier for piping into line-based tools | Retries only cover opening the connection (useful for Azure AS auto-resume/auto-scale delays); they do not retry a failed query execution. Retry waits are interruptible with Ctrl+C. @@ -120,6 +121,10 @@ Every row-returning field (`catalogs`, `cubes`, `dimensions`, `hierarchies`, `le `query`/`dmv` can return more than one entry in `resultSets` when a batch produces multiple result sets. +If a `schema` rowset can't be read (for example, a rowset that isn't supported on the target model), `schema` reports `"partial": true` and lists the failures in a top-level `warnings` array, and the affected rowset object carries its own `error`/`exception` fields. The other rowsets are still returned. + +By default JSON is pretty-printed; pass `--compact` to emit it on a single line for piping into tools like `jq`. + Errors produce `{ "ok": false, "error": ..., "exception": ..., "inner": ... }` and a non-zero exit code: `2` for a general failure, `130` if the command was cancelled (e.g. Ctrl+C). Credential-bearing fragments (`password=`, `pwd=`, `secret=`, `client secret=`, `access token=`) are redacted from `error`/`inner` and stderr before they're written, in case an ADOMD/OLE DB provider echoes the connection string back in an exception message. ## CI/CD diff --git a/src/Adomd.Cli.Tests/BuildConnectionStringTests.cs b/src/Adomd.Cli.Tests/BuildConnectionStringTests.cs new file mode 100644 index 0000000..da929e8 --- /dev/null +++ b/src/Adomd.Cli.Tests/BuildConnectionStringTests.cs @@ -0,0 +1,63 @@ +[Collection("EnvironmentSensitive")] +public class BuildConnectionStringTests +{ + [Fact] + public void ExplicitConnectionString_TakesPrecedence() + { + var settings = new CommonSettings + { + ConnectionString = "Data Source=explicit;Integrated Security=SSPI", + Server = "ignored" + }; + + var result = AnalysisServices.BuildConnectionString(settings); + + Assert.Equal("Data Source=explicit;Integrated Security=SSPI", result); + } + + [Fact] + public void ComposesSspiString_FromServerAndTimeout() + { + var settings = new CommonSettings + { + Server = "FinHubAS", + ConnectTimeoutSeconds = 30 + }; + + var result = AnalysisServices.BuildConnectionString(settings); + + Assert.Equal("Data Source=FinHubAS;Integrated Security=SSPI;Connect Timeout=30", result); + } + + [Fact] + public void IncludesInitialCatalog_WhenCatalogProvided() + { + var settings = new CommonSettings + { + Server = "FinHubAS", + Catalog = "KPILakeRevenue", + ConnectTimeoutSeconds = 15 + }; + + var result = AnalysisServices.BuildConnectionString(settings); + + Assert.Contains("Data Source=FinHubAS", result); + Assert.Contains("Integrated Security=SSPI", result); + Assert.Contains("Connect Timeout=15", result); + Assert.Contains("Initial Catalog=KPILakeRevenue", result); + } + + [Fact] + public void OmitsInitialCatalog_WhenCatalogMissing() + { + var settings = new CommonSettings + { + Server = "FinHubAS", + ConnectTimeoutSeconds = 15 + }; + + var result = AnalysisServices.BuildConnectionString(settings); + + Assert.DoesNotContain("Initial Catalog", result); + } +} diff --git a/src/Adomd.Cli.Tests/CommonSettingsTests.cs b/src/Adomd.Cli.Tests/CommonSettingsTests.cs index 927aa6a..92ce4fe 100644 --- a/src/Adomd.Cli.Tests/CommonSettingsTests.cs +++ b/src/Adomd.Cli.Tests/CommonSettingsTests.cs @@ -1,3 +1,4 @@ +[Collection("EnvironmentSensitive")] public class CommonSettingsTests { private const string EnvVarName = "ADOMD_CONNECTION_STRING"; diff --git a/src/Adomd.Cli.Tests/EnvironmentSensitiveCollection.cs b/src/Adomd.Cli.Tests/EnvironmentSensitiveCollection.cs new file mode 100644 index 0000000..fc46162 --- /dev/null +++ b/src/Adomd.Cli.Tests/EnvironmentSensitiveCollection.cs @@ -0,0 +1,4 @@ +using Xunit; + +[CollectionDefinition("EnvironmentSensitive", DisableParallelization = true)] +public sealed class EnvironmentSensitiveCollection; diff --git a/src/Adomd.Cli.Tests/RowsetResultTests.cs b/src/Adomd.Cli.Tests/RowsetResultTests.cs index 107ca84..80f4472 100644 --- a/src/Adomd.Cli.Tests/RowsetResultTests.cs +++ b/src/Adomd.Cli.Tests/RowsetResultTests.cs @@ -54,14 +54,33 @@ public void ToJson_ExposesRowCountAndTruncatedFlag() } [Fact] - public void Error_WrapsExceptionDetailsAsASingleRow() + public void Error_CarriesExceptionDetailsAndFlagsIsError() { var result = RowsetResult.Error(new InvalidOperationException("boom")); Assert.False(result.Truncated); - Assert.Single(result.Rows); - Assert.Equal("boom", result.Rows[0]["error"]); - Assert.Equal(typeof(InvalidOperationException).FullName, result.Rows[0]["exception"]); + Assert.Empty(result.Rows); + Assert.True(result.IsError); + Assert.Equal("boom", result.ErrorMessage); + Assert.Equal(typeof(InvalidOperationException).FullName, result.ErrorType); + } + + [Fact] + public void ToJson_IncludesErrorFields_WhenErrored() + { + dynamic json = RowsetResult.Error(new InvalidOperationException("boom")).ToJson(); + + Assert.Equal("boom", json.error); + Assert.Equal(typeof(InvalidOperationException).FullName, json.exception); + } + + [Fact] + public void IsError_False_ForNormalResult() + { + var result = new RowsetResult { Rows = [], Truncated = false }; + + Assert.False(result.IsError); + Assert.Null(result.ErrorMessage); } private static DataTable CreateTable(int rowCount) diff --git a/src/Adomd.Cli/Adomd.Cli.csproj b/src/Adomd.Cli/Adomd.Cli.csproj index e390bd6..00a6a06 100644 --- a/src/Adomd.Cli/Adomd.Cli.csproj +++ b/src/Adomd.Cli/Adomd.Cli.csproj @@ -4,7 +4,7 @@ Exe net10.0 adomd - 0.2.0 + 0.2.1 sbroenne JSON-first command-line wrapper for querying Analysis Services through ADOMD.NET. https://github.com/sbroenne/adomd-cli diff --git a/src/Adomd.Cli/Program.cs b/src/Adomd.Cli/Program.cs index 5003892..d1a403d 100644 --- a/src/Adomd.Cli/Program.cs +++ b/src/Adomd.Cli/Program.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; @@ -11,6 +12,10 @@ using Spectre.Console; using Spectre.Console.Cli; +// Emit UTF-8 so non-ASCII catalog/dimension/measure names and data values survive being +// written to the console and parsed downstream, regardless of the host's default code page. +Console.OutputEncoding = Encoding.UTF8; + var app = new CommandApp(); app.Configure(config => { @@ -18,7 +23,9 @@ config.SetApplicationVersion( Assembly.GetExecutingAssembly() .GetCustomAttribute() - ?.InformationalVersion ?? "0.1.0"); + ?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) + ?? "0.0.0"); config.AddCommand("probe") .WithDescription("Open a connection and list visible catalogs."); @@ -125,15 +132,34 @@ protected override object ExecuteJson(SchemaSettings settings, CancellationToken var sets = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Sets, settings.Limit); var custom = new Dictionary(); + var customResults = new List<(string Name, RowsetResult Result)>(); foreach (var rowset in settings.Rowsets) { cancellationToken.ThrowIfCancellationRequested(); - custom[rowset] = AnalysisServices.ReadSchemaRowset(connection, Guid.Parse(rowset), settings.Limit).ToJson(); + var result = AnalysisServices.ReadSchemaRowset(connection, Guid.Parse(rowset), settings.Limit); + customResults.Add((rowset, result)); + custom[rowset] = result.ToJson(); } + var named = new (string Name, RowsetResult Result)[] + { + ("cubes", cubes), + ("dimensions", dimensions), + ("hierarchies", hierarchies), + ("levels", levels), + ("measures", measures), + ("sets", sets) + }; + + var warnings = named.Concat(customResults) + .Where(n => n.Result.IsError) + .Select(n => new { rowset = n.Name, error = n.Result.ErrorMessage, exception = n.Result.ErrorType }) + .ToList(); + return new { - ok = true, + ok = warnings.Count == 0, + partial = warnings.Count > 0, command = "schema", server = settings.Server, catalog = settings.Catalog, @@ -143,7 +169,8 @@ protected override object ExecuteJson(SchemaSettings settings, CancellationToken levels = levels.ToJson(), measures = measures.ToJson(), sets = sets.ToJson(), - custom + custom, + warnings }; } } @@ -175,10 +202,11 @@ public abstract class JsonCommand : Command { protected sealed override int Execute(CommandContext context, TSettings settings, CancellationToken cancellationToken) { + var compact = settings is CommonSettings common && common.Compact; try { cancellationToken.ThrowIfCancellationRequested(); - WriteJson(ExecuteJson(settings, cancellationToken)); + WriteJson(ExecuteJson(settings, cancellationToken), compact); return ExitCodes.Success; } catch (OperationCanceledException) @@ -189,7 +217,7 @@ protected sealed override int Execute(CommandContext context, TSettings settings ok = false, error = "Operation was cancelled.", exception = typeof(OperationCanceledException).FullName - }); + }, compact); return ExitCodes.Cancelled; } catch (Exception ex) @@ -203,18 +231,18 @@ protected sealed override int Execute(CommandContext context, TSettings settings error = message, exception = ex.GetType().FullName, inner = innerMessage - }); + }, compact); return ExitCodes.Error; } } protected abstract object ExecuteJson(TSettings settings, CancellationToken cancellationToken); - private static void WriteJson(object value) + private static void WriteJson(object value, bool compact) { Console.WriteLine(JsonSerializer.Serialize(value, new JsonSerializerOptions { - WriteIndented = true, + WriteIndented = !compact, TypeInfoResolver = new DefaultJsonTypeInfoResolver() })); } @@ -271,6 +299,11 @@ public class CommonSettings : CommandSettings [DefaultValue(1000)] public int RetryDelayMilliseconds { get; init; } + [CommandOption("--compact")] + [Description("Emit single-line (unindented) JSON, which is friendlier for piping into line-based tools.")] + [DefaultValue(false)] + public bool Compact { get; init; } + /// The connection string to use, preferring the explicit option and falling back to the /// ADOMD_CONNECTION_STRING environment variable so secrets don't need to appear on the command line. public string? ResolveConnectionString() => @@ -372,23 +405,27 @@ public sealed class RowsetResult public required IReadOnlyList> Rows { get; init; } public required bool Truncated { get; init; } + /// Non-null when the rowset could not be read; carries the failure message so callers can tell an + /// empty result apart from a failed one. + public string? ErrorMessage { get; init; } + public string? ErrorType { get; init; } + public int RowCount => Rows.Count; + public bool IsError => ErrorMessage is not null; + /// Shapes this result for JSON output as an object carrying its own row count and truncation flag, /// so callers never have to guess whether a short result was complete or cut off by --limit. - public object ToJson() => new { rowCount = RowCount, truncated = Truncated, rows = Rows }; + public object ToJson() => ErrorMessage is null + ? new { rowCount = RowCount, truncated = Truncated, rows = Rows } + : new { rowCount = RowCount, truncated = Truncated, rows = Rows, error = ErrorMessage, exception = ErrorType }; public static RowsetResult Error(Exception ex) => new() { Truncated = false, - Rows = - [ - new Dictionary - { - ["error"] = ex.Message, - ["exception"] = ex.GetType().FullName - } - ] + Rows = [], + ErrorMessage = ex.Message, + ErrorType = ex.GetType().FullName }; } @@ -423,23 +460,7 @@ public static class AnalysisServices { public static AdomdConnection OpenConnection(CommonSettings settings, CancellationToken cancellationToken) { - var connectionString = settings.ResolveConnectionString(); - if (string.IsNullOrWhiteSpace(connectionString)) - { - var parts = new List - { - $"Data Source={settings.Server}", - "Integrated Security=SSPI", - $"Connect Timeout={settings.ConnectTimeoutSeconds}" - }; - - if (!string.IsNullOrWhiteSpace(settings.Catalog)) - { - parts.Add($"Initial Catalog={settings.Catalog}"); - } - - connectionString = string.Join(';', parts); - } + var connectionString = BuildConnectionString(settings); return RetryHelper.Execute(settings.Retries, settings.RetryDelayMilliseconds, cancellationToken, () => { @@ -449,6 +470,32 @@ public static AdomdConnection OpenConnection(CommonSettings settings, Cancellati }); } + /// Resolves the connection string to use: an explicit connection string (option or env var) wins, + /// otherwise a Windows-integrated (SSPI) string is composed from --server/--catalog/--connect-timeout. + /// Extracted so the composition logic can be unit tested without opening a live connection. + internal static string BuildConnectionString(CommonSettings settings) + { + var connectionString = settings.ResolveConnectionString(); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + return connectionString; + } + + var parts = new List + { + $"Data Source={settings.Server}", + "Integrated Security=SSPI", + $"Connect Timeout={settings.ConnectTimeoutSeconds}" + }; + + if (!string.IsNullOrWhiteSpace(settings.Catalog)) + { + parts.Add($"Initial Catalog={settings.Catalog}"); + } + + return string.Join(';', parts); + } + public static RowsetResult ReadCatalogs(AdomdConnection connection, int limit) { var dataSet = connection.GetSchemaDataSet(AdomdSchemaGuid.Catalogs, null);