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
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..211d51e 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
@@ -46,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
@@ -58,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/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..84da702
--- /dev/null
+++ b/CHANGELOG.md
@@ -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 ` 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.
diff --git a/README.md b/README.md
index 0bc1d30..e2ae5f1 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:
@@ -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
@@ -63,18 +71,62 @@ 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` |
+| `--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:
+
+```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:
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..927aa6a
--- /dev/null
+++ b/src/Adomd.Cli.Tests/CommonSettingsTests.cs
@@ -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());
+ }
+
+ /// 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/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/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.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/Adomd.Cli.csproj b/src/Adomd.Cli/Adomd.Cli.csproj
index 4e644ef..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
@@ -19,9 +19,13 @@
-
+
+
+
+
+
-
+
diff --git a/src/Adomd.Cli/Program.cs b/src/Adomd.Cli/Program.cs
index 384b320..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;
@@ -32,12 +34,20 @@
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);
+ using var connection = AnalysisServices.OpenConnection(settings, cancellationToken);
var catalogs = AnalysisServices.ReadCatalogs(connection, settings.Limit);
return new
@@ -49,54 +59,102 @@ 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);
+ using var connection = AnalysisServices.OpenConnection(settings, cancellationToken);
return new
{
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);
+ using var connection = AnalysisServices.OpenConnection(settings, cancellationToken);
+
+ 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);
+ using var connection = AnalysisServices.OpenConnection(settings, cancellationToken);
+ var resultSets = AnalysisServices.ExecuteTabular(connection, query, settings.Limit, settings.QueryTimeoutSeconds, cancellationToken);
return new
{
@@ -104,8 +162,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,24 +177,38 @@ 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)
{
- 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 2;
+ return ExitCodes.Error;
}
}
- protected abstract object ExecuteJson(TSettings settings);
+ protected abstract object ExecuteJson(TSettings settings, CancellationToken cancellationToken);
private static void WriteJson(object value)
{
@@ -146,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 ")]
@@ -157,7 +248,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 +261,28 @@ 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() =>
+ 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)
@@ -187,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();
}
}
@@ -248,11 +366,64 @@ 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
+ }
+ ]
+ };
+}
+
+/// 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.ConnectionString;
+ var connectionString = settings.ResolveConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
var parts = new List
@@ -270,64 +441,99 @@ 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 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 +553,6 @@ public static AdomdConnection OpenConnection(CommonSettings settings)
rows.Add(row);
}
- return rows;
+ return new RowsetResult { Rows = rows, Truncated = table.Rows.Count > limit };
}
}