From 80b6e594ab4fd89afc9146e23a29d6da5b23abcf Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Fri, 10 Jul 2026 08:36:39 +0200 Subject: [PATCH 1/5] Fix repo review findings: tests, multi-result-set, truncation, cancellation, env-var secrets, schema escape hatch, dep bumps - Add Adomd.Cli.Tests xUnit project (24 tests) covering settings validation, connection-string resolution, query resolution, and rowset truncation - ExecuteTabular now iterates NextResult() so multi-statement query batches return all result sets instead of silently dropping all but the first - Every JSON rowset now reports { rowCount, truncated, rows } so callers can tell when --limit cut off data - query/dmv now return a resultSets array (breaking change, pre-1.0) - Wire CancellationToken into query execution (command.Cancel()); distinct exit code 130 for cancellation vs 2 for errors - Support ADOMD_CONNECTION_STRING env var as an alternative to --connection-string to avoid secrets in shell history/process args - Add --rowset escape hatch to schema command for arbitrary schema rowsets - Release workflow verifies tag version matches csproj VersionPrefix before publishing - Bump Microsoft.AnalysisServices.AdomdClient to 19.114.8, Spectre.Console to 0.57.2, .NET SDK pin to 10.0.301 - Update README and add CHANGELOG.md documenting the above Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 6 + Adomd.Cli.slnx | 1 + CHANGELOG.md | 22 ++ README.md | 28 ++- global.json | 2 +- src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj | 25 +++ src/Adomd.Cli.Tests/CommonSettingsTests.cs | 111 ++++++++++ src/Adomd.Cli.Tests/QuerySettingsTests.cs | 98 +++++++++ src/Adomd.Cli.Tests/RowsetResultTests.cs | 80 +++++++ src/Adomd.Cli.Tests/SchemaSettingsTests.cs | 41 ++++ src/Adomd.Cli/Adomd.Cli.csproj | 8 +- src/Adomd.Cli/Program.cs | 229 ++++++++++++++++----- 14 files changed, 602 insertions(+), 55 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj create mode 100644 src/Adomd.Cli.Tests/CommonSettingsTests.cs create mode 100644 src/Adomd.Cli.Tests/QuerySettingsTests.cs create mode 100644 src/Adomd.Cli.Tests/RowsetResultTests.cs create mode 100644 src/Adomd.Cli.Tests/SchemaSettingsTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7094b20..2f601b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,13 +35,13 @@ jobs: global-json-file: global.json - name: Restore - run: dotnet restore .\src\Adomd.Cli\Adomd.Cli.csproj + run: dotnet restore .\Adomd.Cli.slnx - name: Verify formatting run: dotnet format .\Adomd.Cli.slnx --verify-no-changes --no-restore - name: Build - run: dotnet build .\src\Adomd.Cli\Adomd.Cli.csproj --configuration Release --no-restore + run: dotnet build .\Adomd.Cli.slnx --configuration Release --no-restore - name: Test shell: pwsh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 55ba41a..6849baa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: languages: csharp - name: Build - run: dotnet build .\src\Adomd.Cli\Adomd.Cli.csproj --configuration Release + run: dotnet build .\Adomd.Cli.slnx --configuration Release - name: Perform CodeQL analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd7eda4..72bbdb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,12 @@ jobs: throw "Tag '$env:GITHUB_REF_NAME' must be a semantic version tag like v1.2.3." } + [xml]$csproj = Get-Content .\src\Adomd.Cli\Adomd.Cli.csproj + $versionPrefix = $csproj.Project.PropertyGroup.VersionPrefix | Select-Object -First 1 + if ($versionPrefix -ne $version) { + throw "Tag version '$version' does not match VersionPrefix '$versionPrefix' in Adomd.Cli.csproj. Update the csproj before tagging a release." + } + "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV - name: Restore diff --git a/Adomd.Cli.slnx b/Adomd.Cli.slnx index 03ee5be..b8f94d8 100644 --- a/Adomd.Cli.slnx +++ b/Adomd.Cli.slnx @@ -1,5 +1,6 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..010d857 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +### Added +- `Adomd.Cli.Tests` xUnit test project covering settings validation, connection-string resolution, query resolution, and rowset truncation logic. Wired into CI via the existing test-discovery step. +- `ADOMD_CONNECTION_STRING` environment variable as an alternative to `--connection-string`, so secrets don't need to appear on the command line or in shell history. +- `--rowset ` option on `schema` to fetch additional schema rowsets beyond the built-in six. +- Truncation reporting: every rowset in the JSON output now reports `rowCount` and `truncated` so callers can tell whether `--limit` cut off results. +- Multiple result set support: `query`/`dmv` now return a `resultSets` array, so multi-statement batches no longer silently drop all but the first result set. +- Cancellation support: commands honor Ctrl+C during query execution and exit with code `130`. +- Release workflow now verifies the pushed tag matches `VersionPrefix` in `Adomd.Cli.csproj` before publishing. + +### Changed +- **Breaking:** JSON output shape for `catalogs`, `schema`, and `query` changed. Row-bearing fields are now objects of the form `{ rowCount, truncated, rows }` instead of bare arrays, and `query`/`dmv` return `resultSets` instead of a top-level `rows`/`rowCount` pair. +- Updated dependencies: `Microsoft.AnalysisServices.AdomdClient` to `19.114.8`, `Spectre.Console` to `0.57.2`, and the .NET SDK pinned in `global.json` to `10.0.301`. + +## [0.1.0] + +- Initial release: `probe`, `catalogs`, `schema`, `query`, and `dmv` commands. diff --git a/README.md b/README.md index 0bc1d30..83fbcf7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ The CLI uses `Spectre.Console.Cli` for command parsing/help and `Spectre.Console When `--connection-string` is omitted, the CLI builds a connection string with `Integrated Security=SSPI`. Native SSAS TCP with Windows Integrated Auth generally requires Windows. The CI/CD workflows also run on Windows runners because ADOMD.NET is Windows-oriented. +Instead of passing `--connection-string` on the command line (which can leak into shell history and process listings), you can set the `ADOMD_CONNECTION_STRING` environment variable. The explicit option always takes precedence when both are set. + ## Install Download `adomd-cli-win-x64.zip` from the latest GitHub Release, extract it, and run the self-contained Windows executable: @@ -63,18 +65,40 @@ dotnet run --project src\Adomd.Cli -- query --connection-string "` | Analysis Services server | | `--catalog ` | Initial catalog/database | -| `--connection-string ` | Full ADOMD.NET connection string | +| `--connection-string ` | Full ADOMD.NET connection string (falls back to the `ADOMD_CONNECTION_STRING` environment variable) | | `--query ` | Query text; use `--query -` to read from stdin | | `--query-file ` | File containing query text | | `--limit ` | Maximum rows per result set; default `200` | | `--connect-timeout ` | Connection timeout; default `15` | | `--query-timeout ` | Query timeout; default `120` | +| `--rowset ` | `schema` only, repeatable; fetch additional schema rowsets by GUID beyond the built-in six | + +## Output shape + +Every row-returning field (`catalogs`, `cubes`, `dimensions`, `hierarchies`, `levels`, `measures`, `sets`, each `schema --rowset` entry, and each entry in `query`'s `resultSets`) is an object with `rowCount`, `truncated`, and `rows`, so you can always tell whether `--limit` cut off the result: + +```json +{ + "ok": true, + "command": "query", + "resultSetCount": 1, + "rowCount": 2, + "truncated": false, + "resultSets": [ + { "rowCount": 2, "truncated": false, "rows": [ { "col": 1 }, { "col": 2 } ] } + ] +} +``` + +`query`/`dmv` can return more than one entry in `resultSets` when a batch produces multiple result sets. + +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). ## CI/CD - `CI` runs on pushes to `main` and pull requests. It restores, verifies formatting, builds, runs tests when test projects exist, publishes the Windows executable, and uploads the zipped artifact. - `CodeQL` runs on pushes, pull requests, and a weekly schedule. -- `Release` runs for semantic version tags like `v1.2.3`. It publishes the Windows executable, creates a GitHub Release, and attaches the zipped artifact. +- `Release` runs for semantic version tags like `v1.2.3`. It verifies the tag matches `VersionPrefix` in `Adomd.Cli.csproj`, publishes the Windows executable, creates a GitHub Release, and attaches the zipped artifact. To create a release: diff --git a/global.json b/global.json index 34cc7a1..f365c8c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.301", "rollForward": "latestFeature" } } diff --git a/src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj b/src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj new file mode 100644 index 0000000..f511bf0 --- /dev/null +++ b/src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Adomd.Cli.Tests/CommonSettingsTests.cs b/src/Adomd.Cli.Tests/CommonSettingsTests.cs new file mode 100644 index 0000000..66b1966 --- /dev/null +++ b/src/Adomd.Cli.Tests/CommonSettingsTests.cs @@ -0,0 +1,111 @@ +public class CommonSettingsTests +{ + private const string EnvVarName = "ADOMD_CONNECTION_STRING"; + + [Fact] + public void Validate_Fails_WhenNeitherServerNorConnectionStringNorEnvVarProvided() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Limit = 200, ConnectTimeoutSeconds = 15 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--server", result.Message); + } + + [Fact] + public void Validate_Succeeds_WhenServerProvided() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15 }; + + Assert.True(settings.Validate().Successful); + } + + [Fact] + public void Validate_Succeeds_WhenConnectionStringProvided() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { ConnectionString = "Data Source=localhost", Limit = 200, ConnectTimeoutSeconds = 15 }; + + Assert.True(settings.Validate().Successful); + } + + [Fact] + public void Validate_Succeeds_WhenOnlyEnvironmentVariableProvided() + { + using var _ = new EnvironmentVariableScope(EnvVarName, "Data Source=localhost"); + var settings = new CommonSettings { Limit = 200, ConnectTimeoutSeconds = 15 }; + + Assert.True(settings.Validate().Successful); + } + + [Fact] + public void Validate_Fails_WhenLimitIsNotPositive() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 0, ConnectTimeoutSeconds = 15 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--limit", result.Message); + } + + [Fact] + public void Validate_Fails_WhenConnectTimeoutIsNotPositive() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = -1 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--connect-timeout", result.Message); + } + + [Fact] + public void ResolveConnectionString_PrefersExplicitOption_OverEnvironmentVariable() + { + using var _ = new EnvironmentVariableScope(EnvVarName, "Data Source=fromEnv"); + var settings = new CommonSettings { ConnectionString = "Data Source=fromOption" }; + + Assert.Equal("Data Source=fromOption", settings.ResolveConnectionString()); + } + + [Fact] + public void ResolveConnectionString_FallsBackToEnvironmentVariable_WhenOptionOmitted() + { + using var _ = new EnvironmentVariableScope(EnvVarName, "Data Source=fromEnv"); + var settings = new CommonSettings(); + + Assert.Equal("Data Source=fromEnv", settings.ResolveConnectionString()); + } + + [Fact] + public void ResolveConnectionString_ReturnsNull_WhenNeitherIsSet() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings(); + + Assert.Null(settings.ResolveConnectionString()); + } + + /// Sets an environment variable for the lifetime of a test and restores its prior value afterward, + /// so tests don't leak state into each other or into the surrounding process. + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _originalValue; + + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _originalValue); + } +} diff --git a/src/Adomd.Cli.Tests/QuerySettingsTests.cs b/src/Adomd.Cli.Tests/QuerySettingsTests.cs new file mode 100644 index 0000000..f9b9047 --- /dev/null +++ b/src/Adomd.Cli.Tests/QuerySettingsTests.cs @@ -0,0 +1,98 @@ +public class QuerySettingsTests +{ + [Fact] + public void Validate_Fails_WhenNeitherQueryNorQueryFileProvided() + { + var settings = new QuerySettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15, QueryTimeoutSeconds = 120 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--query", result.Message); + } + + [Fact] + public void Validate_Fails_WhenBothQueryAndQueryFileProvided() + { + var settings = new QuerySettings + { + Server = "localhost", + Limit = 200, + ConnectTimeoutSeconds = 15, + QueryTimeoutSeconds = 120, + Query = "SELECT 1", + QueryFile = "query.mdx" + }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("only one", result.Message); + } + + [Fact] + public void Validate_Fails_WhenQueryTimeoutIsNotPositive() + { + var settings = new QuerySettings + { + Server = "localhost", + Limit = 200, + ConnectTimeoutSeconds = 15, + QueryTimeoutSeconds = 0, + Query = "SELECT 1" + }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--query-timeout", result.Message); + } + + [Fact] + public void Validate_Succeeds_WhenQueryProvided() + { + var settings = new QuerySettings + { + Server = "localhost", + Limit = 200, + ConnectTimeoutSeconds = 15, + QueryTimeoutSeconds = 120, + Query = "SELECT 1" + }; + + Assert.True(settings.Validate().Successful); + } + + [Fact] + public void ResolveQuery_ReturnsQueryText_WhenProvided() + { + var settings = new QuerySettings { Query = "EVALUATE {1}" }; + + Assert.Equal("EVALUATE {1}", settings.ResolveQuery()); + } + + [Fact] + public void ResolveQuery_ReadsFromFile_WhenQueryFileProvided() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "EVALUATE {2}"); + var settings = new QuerySettings { QueryFile = path }; + + Assert.Equal("EVALUATE {2}", settings.ResolveQuery()); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ResolveQuery_Throws_WhenNeitherQueryNorQueryFileSet() + { + var settings = new QuerySettings(); + + Assert.Throws(() => settings.ResolveQuery()); + } +} diff --git a/src/Adomd.Cli.Tests/RowsetResultTests.cs b/src/Adomd.Cli.Tests/RowsetResultTests.cs new file mode 100644 index 0000000..107ca84 --- /dev/null +++ b/src/Adomd.Cli.Tests/RowsetResultTests.cs @@ -0,0 +1,80 @@ +using System.Data; + +public class RowsetResultTests +{ + [Fact] + public void DataTableToRows_NotTruncated_WhenRowCountAtOrBelowLimit() + { + var table = CreateTable(3); + + var result = AnalysisServices.DataTableToRows(table, limit: 5); + + Assert.Equal(3, result.RowCount); + Assert.False(result.Truncated); + } + + [Fact] + public void DataTableToRows_Truncated_WhenRowCountExceedsLimit() + { + var table = CreateTable(5); + + var result = AnalysisServices.DataTableToRows(table, limit: 2); + + Assert.Equal(2, result.RowCount); + Assert.True(result.Truncated); + } + + [Fact] + public void DataTableToRows_MapsDbNullToNull() + { + var table = new DataTable(); + table.Columns.Add("Name", typeof(string)); + var row = table.NewRow(); + row["Name"] = DBNull.Value; + table.Rows.Add(row); + + var result = AnalysisServices.DataTableToRows(table, limit: 10); + + Assert.Null(result.Rows[0]["Name"]); + } + + [Fact] + public void ToJson_ExposesRowCountAndTruncatedFlag() + { + var result = new RowsetResult + { + Rows = [new Dictionary { ["a"] = 1 }], + Truncated = true + }; + + dynamic json = result.ToJson(); + + Assert.Equal(1, json.rowCount); + Assert.True(json.truncated); + } + + [Fact] + public void Error_WrapsExceptionDetailsAsASingleRow() + { + 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"]); + } + + private static DataTable CreateTable(int rowCount) + { + var table = new DataTable(); + table.Columns.Add("Id", typeof(int)); + for (var i = 0; i < rowCount; i++) + { + var row = table.NewRow(); + row["Id"] = i; + table.Rows.Add(row); + } + + return table; + } +} diff --git a/src/Adomd.Cli.Tests/SchemaSettingsTests.cs b/src/Adomd.Cli.Tests/SchemaSettingsTests.cs new file mode 100644 index 0000000..e77ecc0 --- /dev/null +++ b/src/Adomd.Cli.Tests/SchemaSettingsTests.cs @@ -0,0 +1,41 @@ +public class SchemaSettingsTests +{ + [Fact] + public void Validate_Fails_WhenRowsetValueIsNotAGuid() + { + var settings = new SchemaSettings + { + Server = "localhost", + Limit = 200, + ConnectTimeoutSeconds = 15, + Rowsets = ["not-a-guid"] + }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("not-a-guid", result.Message); + } + + [Fact] + public void Validate_Succeeds_WhenRowsetValuesAreValidGuids() + { + var settings = new SchemaSettings + { + Server = "localhost", + Limit = 200, + ConnectTimeoutSeconds = 15, + Rowsets = [Guid.NewGuid().ToString()] + }; + + Assert.True(settings.Validate().Successful); + } + + [Fact] + public void Validate_Succeeds_WhenNoRowsetsProvided() + { + var settings = new SchemaSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15 }; + + Assert.True(settings.Validate().Successful); + } +} diff --git a/src/Adomd.Cli/Adomd.Cli.csproj b/src/Adomd.Cli/Adomd.Cli.csproj index 4e644ef..b3b5340 100644 --- a/src/Adomd.Cli/Adomd.Cli.csproj +++ b/src/Adomd.Cli/Adomd.Cli.csproj @@ -19,9 +19,13 @@ - + + + + + - + diff --git a/src/Adomd.Cli/Program.cs b/src/Adomd.Cli/Program.cs index 384b320..7890e83 100644 --- a/src/Adomd.Cli/Program.cs +++ b/src/Adomd.Cli/Program.cs @@ -32,9 +32,17 @@ return app.Run(args); +/// Exit codes returned by JSON commands, exposed for tests and callers scripting against the CLI. +public static class ExitCodes +{ + public const int Success = 0; + public const int Error = 2; + public const int Cancelled = 130; +} + public sealed class ProbeCommand : JsonCommand { - protected override object ExecuteJson(CommonSettings settings) + protected override object ExecuteJson(CommonSettings settings, CancellationToken cancellationToken) { var sw = Stopwatch.StartNew(); using var connection = AnalysisServices.OpenConnection(settings); @@ -49,14 +57,14 @@ protected override object ExecuteJson(CommonSettings settings) os = RuntimeInformation.OSDescription, framework = RuntimeInformation.FrameworkDescription, elapsedMs = sw.ElapsedMilliseconds, - catalogs + catalogs = catalogs.ToJson() }; } } public sealed class CatalogsCommand : JsonCommand { - protected override object ExecuteJson(CommonSettings settings) + protected override object ExecuteJson(CommonSettings settings, CancellationToken cancellationToken) { using var connection = AnalysisServices.OpenConnection(settings); return new @@ -64,39 +72,87 @@ protected override object ExecuteJson(CommonSettings settings) ok = true, command = "catalogs", server = settings.Server, - catalogs = AnalysisServices.ReadCatalogs(connection, settings.Limit) + catalogs = AnalysisServices.ReadCatalogs(connection, settings.Limit).ToJson() }; } } -public sealed class SchemaCommand : JsonCommand +public sealed class SchemaSettings : CommonSettings +{ + [CommandOption("--rowset ")] + [Description("Additional schema rowset GUID(s) to include beyond the built-in set (e.g. a DBSCHEMA_*/MDSCHEMA_* constant). Repeatable.")] + public string[] Rowsets { get; init; } = []; + + public override ValidationResult Validate() + { + var baseResult = base.Validate(); + if (!baseResult.Successful) + { + return baseResult; + } + + foreach (var rowset in Rowsets) + { + if (!Guid.TryParse(rowset, out _)) + { + return ValidationResult.Error($"--rowset value '{rowset}' is not a valid GUID."); + } + } + + return ValidationResult.Success(); + } +} + +public sealed class SchemaCommand : JsonCommand { - protected override object ExecuteJson(CommonSettings settings) + protected override object ExecuteJson(SchemaSettings settings, CancellationToken cancellationToken) { using var connection = AnalysisServices.OpenConnection(settings); + + cancellationToken.ThrowIfCancellationRequested(); + var cubes = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Cubes, settings.Limit); + cancellationToken.ThrowIfCancellationRequested(); + var dimensions = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Dimensions, settings.Limit); + cancellationToken.ThrowIfCancellationRequested(); + var hierarchies = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Hierarchies, settings.Limit); + cancellationToken.ThrowIfCancellationRequested(); + var levels = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Levels, settings.Limit); + cancellationToken.ThrowIfCancellationRequested(); + var measures = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Measures, settings.Limit); + cancellationToken.ThrowIfCancellationRequested(); + var sets = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Sets, settings.Limit); + + var custom = new Dictionary(); + foreach (var rowset in settings.Rowsets) + { + cancellationToken.ThrowIfCancellationRequested(); + custom[rowset] = AnalysisServices.ReadSchemaRowset(connection, Guid.Parse(rowset), settings.Limit).ToJson(); + } + return new { ok = true, command = "schema", server = settings.Server, catalog = settings.Catalog, - cubes = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Cubes, settings.Limit), - dimensions = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Dimensions, settings.Limit), - hierarchies = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Hierarchies, settings.Limit), - levels = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Levels, settings.Limit), - measures = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Measures, settings.Limit), - sets = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Sets, settings.Limit) + cubes = cubes.ToJson(), + dimensions = dimensions.ToJson(), + hierarchies = hierarchies.ToJson(), + levels = levels.ToJson(), + measures = measures.ToJson(), + sets = sets.ToJson(), + custom }; } } public sealed class QueryCommand : JsonCommand { - protected override object ExecuteJson(QuerySettings settings) + protected override object ExecuteJson(QuerySettings settings, CancellationToken cancellationToken) { var query = settings.ResolveQuery(); using var connection = AnalysisServices.OpenConnection(settings); - var rows = AnalysisServices.ExecuteTabular(connection, query, settings.Limit, settings.QueryTimeoutSeconds); + var resultSets = AnalysisServices.ExecuteTabular(connection, query, settings.Limit, settings.QueryTimeoutSeconds, cancellationToken); return new { @@ -104,8 +160,10 @@ protected override object ExecuteJson(QuerySettings settings) command = "query", server = settings.Server, catalog = settings.Catalog, - rowCount = rows.Count, - rows + resultSetCount = resultSets.Count, + rowCount = resultSets.Sum(r => r.RowCount), + truncated = resultSets.Any(r => r.Truncated), + resultSets = resultSets.Select(r => r.ToJson()).ToList() }; } } @@ -117,8 +175,20 @@ protected sealed override int Execute(CommandContext context, TSettings settings { try { - WriteJson(ExecuteJson(settings)); - return 0; + cancellationToken.ThrowIfCancellationRequested(); + WriteJson(ExecuteJson(settings, cancellationToken)); + return ExitCodes.Success; + } + catch (OperationCanceledException) + { + Console.Error.WriteLine("adomd error: operation was cancelled."); + WriteJson(new + { + ok = false, + error = "Operation was cancelled.", + exception = typeof(OperationCanceledException).FullName + }); + return ExitCodes.Cancelled; } catch (Exception ex) { @@ -130,11 +200,11 @@ protected sealed override int Execute(CommandContext context, TSettings settings exception = ex.GetType().FullName, inner = ex.InnerException?.Message }); - return 2; + return ExitCodes.Error; } } - protected abstract object ExecuteJson(TSettings settings); + protected abstract object ExecuteJson(TSettings settings, CancellationToken cancellationToken); private static void WriteJson(object value) { @@ -157,7 +227,7 @@ public class CommonSettings : CommandSettings public string? Catalog { get; init; } [CommandOption("--connection-string ")] - [Description("Full ADOMD.NET connection string.")] + [Description("Full ADOMD.NET connection string. Falls back to the ADOMD_CONNECTION_STRING environment variable when omitted, which keeps secrets out of the process command line and shell history.")] public string? ConnectionString { get; init; } [CommandOption("--limit ")] @@ -170,11 +240,18 @@ public class CommonSettings : CommandSettings [DefaultValue(15)] public int ConnectTimeoutSeconds { 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() => + string.IsNullOrWhiteSpace(ConnectionString) + ? Environment.GetEnvironmentVariable("ADOMD_CONNECTION_STRING") + : ConnectionString; + public override ValidationResult Validate() { - if (string.IsNullOrWhiteSpace(ConnectionString) && string.IsNullOrWhiteSpace(Server)) + if (string.IsNullOrWhiteSpace(ResolveConnectionString()) && string.IsNullOrWhiteSpace(Server)) { - return ValidationResult.Error("Specify --server or --connection-string."); + return ValidationResult.Error("Specify --server, --connection-string, or set the ADOMD_CONNECTION_STRING environment variable."); } if (Limit <= 0) @@ -248,11 +325,37 @@ public string ResolveQuery() } } +/// A set of rows capped at a caller-supplied limit, along with whether more rows existed than were returned. +public sealed class RowsetResult +{ + public required IReadOnlyList> Rows { get; init; } + public required bool Truncated { get; init; } + + public int RowCount => Rows.Count; + + /// 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 static RowsetResult Error(Exception ex) => new() + { + Truncated = false, + Rows = + [ + new Dictionary + { + ["error"] = ex.Message, + ["exception"] = ex.GetType().FullName + } + ] + }; +} + public static class AnalysisServices { public static AdomdConnection OpenConnection(CommonSettings settings) { - var connectionString = settings.ConnectionString; + var connectionString = settings.ResolveConnectionString(); if (string.IsNullOrWhiteSpace(connectionString)) { var parts = new List @@ -275,59 +378,91 @@ public static AdomdConnection OpenConnection(CommonSettings settings) return connection; } - public static IReadOnlyList> ReadCatalogs(AdomdConnection connection, int limit) + public static RowsetResult ReadCatalogs(AdomdConnection connection, int limit) { var dataSet = connection.GetSchemaDataSet(AdomdSchemaGuid.Catalogs, null); - return dataSet.Tables.Count == 0 ? [] : DataTableToRows(dataSet.Tables[0], limit); + return dataSet.Tables.Count == 0 + ? new RowsetResult { Rows = [], Truncated = false } + : DataTableToRows(dataSet.Tables[0], limit); } - public static IReadOnlyList> ReadSchemaRowset(AdomdConnection connection, Guid schema, int limit) + public static RowsetResult ReadSchemaRowset(AdomdConnection connection, Guid schema, int limit) { try { var dataSet = connection.GetSchemaDataSet(schema, null); - return dataSet.Tables.Count == 0 ? [] : DataTableToRows(dataSet.Tables[0], limit); + return dataSet.Tables.Count == 0 + ? new RowsetResult { Rows = [], Truncated = false } + : DataTableToRows(dataSet.Tables[0], limit); } catch (Exception ex) { - return - [ - new Dictionary - { - ["error"] = ex.Message, - ["exception"] = ex.GetType().FullName - } - ]; + return RowsetResult.Error(ex); } } - public static List> ExecuteTabular( + /// Executes a query and returns one per result set, since a single + /// MDX/DAX/DMX batch can return multiple result sets that were previously silently dropped. + public static List ExecuteTabular( AdomdConnection connection, string query, int limit, - int queryTimeoutSeconds) + int queryTimeoutSeconds, + CancellationToken cancellationToken) { using var command = connection.CreateCommand(); command.CommandText = query; command.CommandTimeout = queryTimeoutSeconds; + using var registration = cancellationToken.Register(() => + { + try + { + command.Cancel(); + } + catch + { + // Best-effort: the command may have already completed or the provider may not support cancellation. + } + }); + + cancellationToken.ThrowIfCancellationRequested(); using var reader = command.ExecuteReader(); - var rows = new List>(); - while (reader.Read() && rows.Count < limit) + var resultSets = new List(); + + do { - var row = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < reader.FieldCount; i++) + cancellationToken.ThrowIfCancellationRequested(); + var rows = new List>(); + var truncated = false; + + while (reader.Read()) { - row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + if (rows.Count < limit) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + } + + rows.Add(row); + } + else + { + // Keep draining so NextResult() can advance cleanly, but stop storing rows past the limit. + truncated = true; + } } - rows.Add(row); + resultSets.Add(new RowsetResult { Rows = rows, Truncated = truncated }); } + while (reader.NextResult()); - return rows; + return resultSets; } - private static List> DataTableToRows(DataTable table, int limit) + internal static RowsetResult DataTableToRows(DataTable table, int limit) { var rows = new List>(); foreach (DataRow dataRow in table.Rows) @@ -347,6 +482,6 @@ public static AdomdConnection OpenConnection(CommonSettings settings) rows.Add(row); } - return rows; + return new RowsetResult { Rows = rows, Truncated = table.Rows.Count > limit }; } } From 33c60c4364e12fd6fd90906092972352dada37b3 Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Fri, 10 Jul 2026 08:40:33 +0200 Subject: [PATCH 2/5] Add dependabot coverage for Adomd.Cli.Tests NuGet packages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 401ebef..1f11de4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,9 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + + - package-ecosystem: nuget + directory: /src/Adomd.Cli.Tests + schedule: + interval: weekly + open-pull-requests-limit: 5 From 5df2bfe7c098b440d765cb477ab425a4b58a18ed Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Fri, 10 Jul 2026 08:48:44 +0200 Subject: [PATCH 3/5] Add connection retries, secret redaction, release checksums, and rowset GUID docs - CommonSettings: --retries/--retry-delay-ms options; OpenConnection retries via new cancellable RetryHelper (unit tested independently of ADOMD) - SecretRedactor: scrubs password/pwd/secret/client secret/access token fragments from exception messages before they hit stderr or the JSON error payload, in case a provider echoes the connection string - release.yml: generates and publishes a SHA256 checksum file alongside the zip - README: documents --retries/--retry-delay-ms, checksum verification, redaction behavior, and a table of common schema --rowset GUIDs sourced from AdomdSchemaGuid - 18 new tests (RetryHelper, SecretRedactor, retry option validation); 42/42 passing - Verified live against FinHubAS/KPILakeRevenue: retries observably extend connection attempts on failure, normal probe/schema/query paths unaffected Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 12 ++- CHANGELOG.md | 5 ++ README.md | 32 ++++++- src/Adomd.Cli.Tests/CommonSettingsTests.cs | 33 +++++++ src/Adomd.Cli.Tests/RetryHelperTests.cs | 100 +++++++++++++++++++++ src/Adomd.Cli.Tests/SecretRedactorTests.cs | 45 ++++++++++ src/Adomd.Cli/Program.cs | 93 ++++++++++++++++--- 7 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 src/Adomd.Cli.Tests/RetryHelperTests.cs create mode 100644 src/Adomd.Cli.Tests/SecretRedactorTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72bbdb1..211d51e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,11 +52,19 @@ jobs: shell: pwsh run: Compress-Archive -Path .\artifacts\publish\win-x64\* -DestinationPath .\artifacts\adomd-cli-win-x64.zip -Force + - name: Generate checksum + shell: pwsh + run: | + $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: Upload executable artifact uses: actions/upload-artifact@v7 with: name: adomd-cli-win-x64 - path: artifacts\adomd-cli-win-x64.zip + path: | + artifacts\adomd-cli-win-x64.zip + artifacts\adomd-cli-win-x64.zip.sha256 if-no-files-found: error - name: Create GitHub release @@ -64,4 +72,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create $env:GITHUB_REF_NAME ./artifacts/adomd-cli-win-x64.zip --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 "Release $env:GITHUB_REF_NAME" --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 010d857..1378916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to this project are documented in this file. - Multiple result set support: `query`/`dmv` now return a `resultSets` array, so multi-statement batches no longer silently drop all but the first result set. - Cancellation support: commands honor Ctrl+C during query execution and exit with code `130`. - Release workflow now verifies the pushed tag matches `VersionPrefix` in `Adomd.Cli.csproj` before publishing. +- `--retries`/`--retry-delay-ms` options to retry opening the connection on transient failures, with a cancellable delay between attempts. +- Secrets (password/pwd/secret/client secret/access token fragments) are now redacted from error output before it's written to stderr or the JSON payload. +- Release artifacts now include a SHA256 checksum file alongside the zip. +- Dependabot now also tracks NuGet packages in `Adomd.Cli.Tests`. +- README table of common `schema --rowset` GUIDs. ### Changed - **Breaking:** JSON output shape for `catalogs`, `schema`, and `query` changed. Row-bearing fields are now objects of the form `{ rowCount, truncated, rows }` instead of bare arrays, and `query`/`dmv` return `resultSets` instead of a top-level `rows`/`rowCount` pair. diff --git a/README.md b/README.md index 83fbcf7..e2ae5f1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ Download `adomd-cli-win-x64.zip` from the latest GitHub Release, extract it, and .\adomd.exe --help ``` +Each release also publishes an `adomd-cli-win-x64.zip.sha256` checksum file. Verify the download before running it: + +```powershell +(Get-FileHash .\adomd-cli-win-x64.zip -Algorithm SHA256).Hash -eq (Get-Content .\adomd-cli-win-x64.zip.sha256).Split(' ')[0].ToUpperInvariant() +``` + From a local clone: ```powershell @@ -71,8 +77,30 @@ dotnet run --project src\Adomd.Cli -- query --connection-string "` | Maximum rows per result set; default `200` | | `--connect-timeout ` | Connection timeout; default `15` | | `--query-timeout ` | Query timeout; default `120` | +| `--retries ` | 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 | +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. + +### Common schema rowset GUIDs + +For use with `schema --rowset `, beyond the six built into every `schema` response (Cubes, Dimensions, Hierarchies, Levels, Measures, Sets): + +| Rowset | GUID | +| --- | --- | +| MDSCHEMA_MEASUREGROUPS | `e1625ebf-fa96-42fd-bea6-db90adafd96b` | +| MDSCHEMA_KPIS | `2ae44109-ed3d-4842-b16f-b694d1cb0e3f` | +| MDSCHEMA_ACTIONS | `a07ccd08-8148-11d0-87bb-00c04fc33942` | +| MDSCHEMA_FUNCTIONS | `a07ccd07-8148-11d0-87bb-00c04fc33942` | +| DBSCHEMA_TABLES | `c8b52229-5cf3-11ce-ade5-00aa0044773d` | +| DISCOVER_XML_METADATA | `3444b255-171e-4cb9-ad98-19e57888a75f` | +| TMSCHEMA_ROLES (Tabular) | `a07ccd6b-8148-11d0-87bb-00c04fc33942` | +| TMSCHEMA_PERSPECTIVES (Tabular) | `a07ccd66-8148-11d0-87bb-00c04fc33942` | +| TMSCHEMA_CALCULATION_GROUPS (Tabular) | `a07ccd76-8148-11d0-87bb-00c04fc33942` | + +The full set of ~150 rowset GUIDs is defined by `Microsoft.AnalysisServices.AdomdClient.AdomdSchemaGuid`. + ## Output shape Every row-returning field (`catalogs`, `cubes`, `dimensions`, `hierarchies`, `levels`, `measures`, `sets`, each `schema --rowset` entry, and each entry in `query`'s `resultSets`) is an object with `rowCount`, `truncated`, and `rows`, so you can always tell whether `--limit` cut off the result: @@ -92,13 +120,13 @@ 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. -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). +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 - `CI` runs on pushes to `main` and pull requests. It restores, verifies formatting, builds, runs tests when test projects exist, publishes the Windows executable, and uploads the zipped artifact. - `CodeQL` runs on pushes, pull requests, and a weekly schedule. -- `Release` runs for semantic version tags like `v1.2.3`. It verifies the tag matches `VersionPrefix` in `Adomd.Cli.csproj`, publishes the Windows executable, creates a GitHub Release, and attaches the zipped artifact. +- `Release` runs for semantic version tags like `v1.2.3`. It verifies the tag matches `VersionPrefix` in `Adomd.Cli.csproj`, publishes the Windows executable, creates a GitHub Release, and attaches the zipped artifact plus a SHA256 checksum file. To create a release: diff --git a/src/Adomd.Cli.Tests/CommonSettingsTests.cs b/src/Adomd.Cli.Tests/CommonSettingsTests.cs index 66b1966..927aa6a 100644 --- a/src/Adomd.Cli.Tests/CommonSettingsTests.cs +++ b/src/Adomd.Cli.Tests/CommonSettingsTests.cs @@ -65,6 +65,39 @@ public void Validate_Fails_WhenConnectTimeoutIsNotPositive() Assert.Contains("--connect-timeout", result.Message); } + [Fact] + public void Validate_Fails_WhenRetriesIsNegative() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15, Retries = -1 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--retries", result.Message); + } + + [Fact] + public void Validate_Fails_WhenRetryDelayIsNegative() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15, RetryDelayMilliseconds = -1 }; + + var result = settings.Validate(); + + Assert.False(result.Successful); + Assert.Contains("--retry-delay-ms", result.Message); + } + + [Fact] + public void Validate_Succeeds_WhenRetriesAndDelayAreZero() + { + using var _ = new EnvironmentVariableScope(EnvVarName, null); + var settings = new CommonSettings { Server = "localhost", Limit = 200, ConnectTimeoutSeconds = 15, Retries = 0, RetryDelayMilliseconds = 0 }; + + Assert.True(settings.Validate().Successful); + } + [Fact] public void ResolveConnectionString_PrefersExplicitOption_OverEnvironmentVariable() { diff --git a/src/Adomd.Cli.Tests/RetryHelperTests.cs b/src/Adomd.Cli.Tests/RetryHelperTests.cs new file mode 100644 index 0000000..6f8bab6 --- /dev/null +++ b/src/Adomd.Cli.Tests/RetryHelperTests.cs @@ -0,0 +1,100 @@ +public class RetryHelperTests +{ + [Fact] + public void Execute_ReturnsImmediately_WhenActionSucceedsFirstTry() + { + var attempts = 0; + var result = RetryHelper.Execute(retries: 3, delayMilliseconds: 0, CancellationToken.None, () => + { + attempts++; + return "ok"; + }); + + Assert.Equal("ok", result); + Assert.Equal(1, attempts); + } + + [Fact] + public void Execute_RetriesUntilSuccess_WithinBudget() + { + var attempts = 0; + var result = RetryHelper.Execute(retries: 3, delayMilliseconds: 0, CancellationToken.None, () => + { + attempts++; + if (attempts < 3) + { + throw new InvalidOperationException("transient"); + } + + return "ok"; + }); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + } + + [Fact] + public void Execute_ThrowsFinalException_WhenAllAttemptsFail() + { + var attempts = 0; + + var ex = Assert.Throws(() => + RetryHelper.Execute(retries: 2, delayMilliseconds: 0, CancellationToken.None, () => + { + attempts++; + throw new InvalidOperationException($"attempt {attempts}"); + })); + + Assert.Equal(3, attempts); // initial try + 2 retries + Assert.Equal("attempt 3", ex.Message); + } + + [Fact] + public void Execute_MakesExactlyOneAttempt_WhenRetriesIsZero() + { + var attempts = 0; + + Assert.Throws(() => + RetryHelper.Execute(retries: 0, delayMilliseconds: 0, CancellationToken.None, () => + { + attempts++; + throw new InvalidOperationException("boom"); + })); + + Assert.Equal(1, attempts); + } + + [Fact] + public void Execute_ThrowsOperationCanceled_WhenCancelledBeforeFirstAttempt() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var attempts = 0; + + Assert.Throws(() => + RetryHelper.Execute(retries: 3, delayMilliseconds: 0, cts.Token, () => + { + attempts++; + return "unreachable"; + })); + + Assert.Equal(0, attempts); + } + + [Fact] + public void Execute_StopsRetrying_WhenCancelledDuringBackoffDelay() + { + using var cts = new CancellationTokenSource(); + var attempts = 0; + + Assert.Throws(() => + RetryHelper.Execute(retries: 5, delayMilliseconds: 50, cts.Token, () => + { + attempts++; + cts.Cancel(); + throw new InvalidOperationException("transient"); + })); + + Assert.Equal(1, attempts); + } +} diff --git a/src/Adomd.Cli.Tests/SecretRedactorTests.cs b/src/Adomd.Cli.Tests/SecretRedactorTests.cs new file mode 100644 index 0000000..6507706 --- /dev/null +++ b/src/Adomd.Cli.Tests/SecretRedactorTests.cs @@ -0,0 +1,45 @@ +public class SecretRedactorTests +{ + [Theory] + [InlineData("Data Source=x;Password=hunter2;User ID=me", "Data Source=x;password=***;User ID=me")] + [InlineData("Data Source=x;Pwd=hunter2", "Data Source=x;pwd=***")] + [InlineData("Client Secret=abc123;Data Source=x", "client secret=***;Data Source=x")] + [InlineData("Secret=abc123", "secret=***")] + [InlineData("Access Token=abc.def.ghi", "access token=***")] + public void Redact_MasksCredentialValues(string input, string expected) + { + Assert.Equal(expected, SecretRedactor.Redact(input)); + } + + [Fact] + public void Redact_IsCaseInsensitiveForKeyName() + { + var result = SecretRedactor.Redact("PASSWORD=hunter2"); + + Assert.DoesNotContain("hunter2", result); + } + + [Fact] + public void Redact_LeavesUnrelatedTextUnchanged() + { + const string message = "A connection cannot be made. Ensure that the server is running."; + + Assert.Equal(message, SecretRedactor.Redact(message)); + } + + [Fact] + public void Redact_ReturnsNull_WhenInputIsNull() + { + Assert.Null(SecretRedactor.Redact(null)); + } + + [Fact] + public void Redact_HandlesMultipleSecretsInSameMessage() + { + var result = SecretRedactor.Redact("Password=hunter2;Data Source=x;Client Secret=abc123"); + + Assert.DoesNotContain("hunter2", result); + Assert.DoesNotContain("abc123", result); + Assert.Contains("Data Source=x", result); + } +} diff --git a/src/Adomd.Cli/Program.cs b/src/Adomd.Cli/Program.cs index 7890e83..5003892 100644 --- a/src/Adomd.Cli/Program.cs +++ b/src/Adomd.Cli/Program.cs @@ -1,10 +1,12 @@ using System.ComponentModel; using System.Data; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; using Microsoft.AnalysisServices.AdomdClient; using Spectre.Console; using Spectre.Console.Cli; @@ -45,7 +47,7 @@ public sealed class ProbeCommand : JsonCommand protected override object ExecuteJson(CommonSettings settings, CancellationToken cancellationToken) { var sw = Stopwatch.StartNew(); - using var connection = AnalysisServices.OpenConnection(settings); + using var connection = AnalysisServices.OpenConnection(settings, cancellationToken); var catalogs = AnalysisServices.ReadCatalogs(connection, settings.Limit); return new @@ -66,7 +68,7 @@ public sealed class CatalogsCommand : JsonCommand { protected override object ExecuteJson(CommonSettings settings, CancellationToken cancellationToken) { - using var connection = AnalysisServices.OpenConnection(settings); + using var connection = AnalysisServices.OpenConnection(settings, cancellationToken); return new { ok = true, @@ -107,7 +109,7 @@ public sealed class SchemaCommand : JsonCommand { protected override object ExecuteJson(SchemaSettings settings, CancellationToken cancellationToken) { - using var connection = AnalysisServices.OpenConnection(settings); + using var connection = AnalysisServices.OpenConnection(settings, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); var cubes = AnalysisServices.ReadSchemaRowset(connection, AdomdSchemaGuid.Cubes, settings.Limit); @@ -151,7 +153,7 @@ public sealed class QueryCommand : JsonCommand protected override object ExecuteJson(QuerySettings settings, CancellationToken cancellationToken) { var query = settings.ResolveQuery(); - using var connection = AnalysisServices.OpenConnection(settings); + using var connection = AnalysisServices.OpenConnection(settings, cancellationToken); var resultSets = AnalysisServices.ExecuteTabular(connection, query, settings.Limit, settings.QueryTimeoutSeconds, cancellationToken); return new @@ -192,13 +194,15 @@ protected sealed override int Execute(CommandContext context, TSettings settings } catch (Exception ex) { - Console.Error.WriteLine($"adomd error: {ex.Message}"); + var message = SecretRedactor.Redact(ex.Message); + var innerMessage = SecretRedactor.Redact(ex.InnerException?.Message); + Console.Error.WriteLine($"adomd error: {message}"); WriteJson(new { ok = false, - error = ex.Message, + error = message, exception = ex.GetType().FullName, - inner = ex.InnerException?.Message + inner = innerMessage }); return ExitCodes.Error; } @@ -216,6 +220,23 @@ private static void WriteJson(object value) } } +/// Scrubs credential-bearing fragments (passwords, secrets, tokens) out of text before it is ever +/// written to stderr or the JSON error payload. Some ADOMD/OLE DB providers echo the full connection string +/// back in exception messages, which would otherwise leak secrets passed via --connection-string. +public static partial class SecretRedactor +{ + [GeneratedRegex( + @"(?password|pwd|secret|client secret|access token)\s*=\s*[^;]*", + RegexOptions.IgnoreCase)] + private static partial Regex SecretPattern(); + + [return: NotNullIfNotNull(nameof(text))] + public static string? Redact(string? text) => + text is null + ? null + : SecretPattern().Replace(text, match => $"{match.Groups["key"].Value.ToLowerInvariant()}=***"); +} + public class CommonSettings : CommandSettings { [CommandOption("--server ")] @@ -240,6 +261,16 @@ public class CommonSettings : CommandSettings [DefaultValue(15)] public int ConnectTimeoutSeconds { get; init; } + [CommandOption("--retries ")] + [Description("Number of times to retry opening the connection after a transient failure.")] + [DefaultValue(0)] + public int Retries { get; init; } + + [CommandOption("--retry-delay-ms ")] + [Description("Delay between connection retry attempts, in milliseconds.")] + [DefaultValue(1000)] + public int RetryDelayMilliseconds { 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() => @@ -264,6 +295,16 @@ public override ValidationResult Validate() return ValidationResult.Error("--connect-timeout must be a positive integer."); } + if (Retries < 0) + { + return ValidationResult.Error("--retries must be zero or a positive integer."); + } + + if (RetryDelayMilliseconds < 0) + { + return ValidationResult.Error("--retry-delay-ms must be zero or a positive integer."); + } + return ValidationResult.Success(); } } @@ -351,9 +392,36 @@ public sealed class RowsetResult }; } +/// Runs an operation with a bounded number of retries and a cancellable delay between attempts. +/// Extracted as a standalone helper so retry/backoff behavior can be unit tested without a live connection. +public static class RetryHelper +{ + public static T Execute(int retries, int delayMilliseconds, CancellationToken cancellationToken, Func action) + { + var totalAttempts = retries + 1; + for (var attempt = 1; attempt <= totalAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return action(); + } + catch when (attempt < totalAttempts) + { + // Transient failure with attempts remaining: wait (interruptibly) and try again. + cancellationToken.WaitHandle.WaitOne(delayMilliseconds); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + throw new UnreachableException("Retry loop exited without returning or throwing."); + } +} + public static class AnalysisServices { - public static AdomdConnection OpenConnection(CommonSettings settings) + public static AdomdConnection OpenConnection(CommonSettings settings, CancellationToken cancellationToken) { var connectionString = settings.ResolveConnectionString(); if (string.IsNullOrWhiteSpace(connectionString)) @@ -373,9 +441,12 @@ public static AdomdConnection OpenConnection(CommonSettings settings) connectionString = string.Join(';', parts); } - var connection = new AdomdConnection(connectionString); - connection.Open(); - return connection; + return RetryHelper.Execute(settings.Retries, settings.RetryDelayMilliseconds, cancellationToken, () => + { + var connection = new AdomdConnection(connectionString); + connection.Open(); + return connection; + }); } public static RowsetResult ReadCatalogs(AdomdConnection connection, int limit) From 5a33554ce5003155e42fe8dcfa2e1f1e980475a1 Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Fri, 10 Jul 2026 08:55:20 +0200 Subject: [PATCH 4/5] Bump version to 0.2.0 for release Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/Adomd.Cli/Adomd.Cli.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1378916..84da702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -## [Unreleased] +## [0.2.0] ### Added - `Adomd.Cli.Tests` xUnit test project covering settings validation, connection-string resolution, query resolution, and rowset truncation logic. Wired into CI via the existing test-discovery step. diff --git a/src/Adomd.Cli/Adomd.Cli.csproj b/src/Adomd.Cli/Adomd.Cli.csproj index b3b5340..e390bd6 100644 --- a/src/Adomd.Cli/Adomd.Cli.csproj +++ b/src/Adomd.Cli/Adomd.Cli.csproj @@ -4,7 +4,7 @@ Exe net10.0 adomd - 0.1.0 + 0.2.0 sbroenne JSON-first command-line wrapper for querying Analysis Services through ADOMD.NET. https://github.com/sbroenne/adomd-cli From b0daca1af7ea7f8224ac994b1a910a7e6e75bcab Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Fri, 10 Jul 2026 09:23:09 +0200 Subject: [PATCH 5/5] v0.2.1: UTF-8 output, --compact, schema warnings, ReadyToRun, changelog release notes - Force UTF-8 console output so non-ASCII AS names/values serialize correctly - Add --compact option for single-line JSON output - Surface schema rowset failures via top-level partial/warnings + per-rowset error fields - Extract testable BuildConnectionString; add unit tests - Fix stale version fallback string - release.yml: ReadyToRun publish + release notes extracted from CHANGELOG - Serialize env-var-sensitive tests via shared xUnit collection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 19 ++- CHANGELOG.md | 14 +++ README.md | 5 + .../BuildConnectionStringTests.cs | 63 ++++++++++ src/Adomd.Cli.Tests/CommonSettingsTests.cs | 1 + .../EnvironmentSensitiveCollection.cs | 4 + src/Adomd.Cli.Tests/RowsetResultTests.cs | 27 +++- src/Adomd.Cli/Adomd.Cli.csproj | 2 +- src/Adomd.Cli/Program.cs | 117 ++++++++++++------ 9 files changed, 210 insertions(+), 42 deletions(-) create mode 100644 src/Adomd.Cli.Tests/BuildConnectionStringTests.cs create mode 100644 src/Adomd.Cli.Tests/EnvironmentSensitiveCollection.cs 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);