Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 16 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,16 +52,24 @@ 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
shell: pwsh
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
1 change: 1 addition & 0 deletions Adomd.Cli.slnx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Adomd.Cli/Adomd.Cli.csproj" />
<Project Path="src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj" />
</Folder>
</Solution>
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

All notable changes to this project are documented in this file.

## [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.
- `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 <GUID>` 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.
- `--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.
- 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.
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,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
Expand Down Expand Up @@ -63,18 +71,62 @@ dotnet run --project src\Adomd.Cli -- query --connection-string "<connection str
| --- | --- |
| `--server <name>` | Analysis Services server |
| `--catalog <name>` | Initial catalog/database |
| `--connection-string <value>` | Full ADOMD.NET connection string |
| `--connection-string <value>` | Full ADOMD.NET connection string (falls back to the `ADOMD_CONNECTION_STRING` environment variable) |
| `--query <text>` | Query text; use `--query -` to read from stdin |
| `--query-file <path>` | File containing query text |
| `--limit <n>` | Maximum rows per result set; default `200` |
| `--connect-timeout <sec>` | Connection timeout; default `15` |
| `--query-timeout <sec>` | Query timeout; default `120` |
| `--retries <n>` | Number of times to retry opening the connection after a transient failure; default `0` |
| `--retry-delay-ms <ms>` | Delay between connection retry attempts; default `1000` |
| `--rowset <guid>` | `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 <guid>`, 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:

```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). 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 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:

Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.300",
"version": "10.0.301",
"rollForward": "latestFeature"
}
}
25 changes: 25 additions & 0 deletions src/Adomd.Cli.Tests/Adomd.Cli.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Adomd.Cli\Adomd.Cli.csproj" />
</ItemGroup>

</Project>
144 changes: 144 additions & 0 deletions src/Adomd.Cli.Tests/CommonSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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 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()
{
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());
}

/// <summary>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.</summary>
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);
}
}
Loading
Loading