From 0d9195dd5bcd80dd63d5ff907c259f549365694e Mon Sep 17 00:00:00 2001
From: Luke Kim <80174+lukekim@users.noreply.github.com>
Date: Sat, 25 Jul 2026 13:30:48 -0700
Subject: [PATCH] feat: add refresh options for on-demand dataset refresh
`RefreshDatasetAsync` could only trigger a dataset refresh using the
dataset's configured settings. Every other Spice SDK (Go, Python, Rust,
Java, TypeScript) accepts per-refresh overrides, so .NET callers had no
way to run a filtered or append-mode refresh without editing the
Spicepod.
Add a `RefreshOptions` type carrying the three overrides the runtime
accepts on `POST /v1/datasets/{name}/acceleration/refresh`:
- `RefreshSql` -> `refresh_sql`
- `RefreshMode` -> `refresh_mode` (`Full` / `Append`)
- `MaxJitter` -> `refresh_jitter_max` (e.g. "10s", "1500ms")
Unset options are omitted from the body so the runtime falls back to the
dataset configuration, matching the other SDKs.
The existing single-argument `RefreshDatasetAsync(string)` overload is
unchanged, so this is source and binary compatible. It now posts `{}`
with `application/json` instead of an empty body, which is what the
other SDKs send and what the runtime documents.
netstandard2.0 gains an explicit `System.Text.Json` reference; it is
in-box for .NET 8.0+.
---
README.md | 42 ++++++
Spice/Spice.csproj | 5 +
Spice/src/Datasets/RefreshOptions.cs | 179 +++++++++++++++++++++++++
Spice/src/Http/ISpiceHttpClient.cs | 13 ++
Spice/src/Http/SpiceHttpClient.cs | 17 ++-
Spice/src/SpiceClient.cs | 26 +++-
SpiceTest/RefreshDatasetRequestTest.cs | 157 ++++++++++++++++++++++
SpiceTest/RefreshOptionsTest.cs | 163 ++++++++++++++++++++++
8 files changed, 597 insertions(+), 5 deletions(-)
create mode 100644 Spice/src/Datasets/RefreshOptions.cs
create mode 100644 SpiceTest/RefreshDatasetRequestTest.cs
create mode 100644 SpiceTest/RefreshOptionsTest.cs
diff --git a/README.md b/README.md
index 3277af8..5dd9408 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,43 @@ using var client = new SpiceClientBuilder().Build();
await client.RefreshDatasetAsync("my_dataset");
```
+##### Refresh Options
+
+Override the dataset's configured refresh settings for a single refresh by passing
+`RefreshOptions`. Any option left unset falls back to the dataset's Spicepod configuration.
+
+```csharp
+using Spice;
+using Spice.Datasets;
+
+using var client = new SpiceClientBuilder().Build();
+
+await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions()
+ .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
+ .WithRefreshMode(RefreshMode.Append)
+ .WithMaxJitter(TimeSpan.FromSeconds(10)));
+```
+
+Object initializer syntax works too:
+
+```csharp
+await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions
+{
+ RefreshSql = "SELECT * FROM taxi_trips WHERE tip_amount > 10.0",
+ RefreshMode = RefreshMode.Append,
+ MaxJitter = TimeSpan.FromSeconds(10),
+});
+```
+
+| Option | Type | Description |
+| --- | --- | --- |
+| `RefreshSql` | `string` | The SQL statement used for this refresh. Defaults to the dataset's `refresh_sql`. |
+| `RefreshMode` | `RefreshMode` | `Full` replaces the accelerated data; `Append` adds newly returned rows. Defaults to the dataset's `refresh_mode`. |
+| `MaxJitter` | `TimeSpan` | Maximum jitter added before the refresh starts. Defaults to the dataset's `refresh_jitter_max`. |
+
+> **Note**: On-demand refreshes apply to the `full` and `append` refresh modes. Datasets accelerated with
+> `changes` mode are kept up to date by change data capture and are not refreshed through this API.
+
#### Custom Connection Settings
```csharp
@@ -207,12 +244,17 @@ var data = await client.Query(
```csharp
using Spice;
+using Spice.Datasets;
using var client = new SpiceClientBuilder()
.WithSpiceCloud("API_KEY")
.Build();
await client.RefreshDatasetAsync("my_dataset");
+
+// Or with refresh overrides for this refresh only
+await client.RefreshDatasetAsync("my_dataset", new RefreshOptions()
+ .WithRefreshMode(RefreshMode.Append));
```
### Memory Management
diff --git a/Spice/Spice.csproj b/Spice/Spice.csproj
index 2e77ccf..529ddb5 100644
--- a/Spice/Spice.csproj
+++ b/Spice/Spice.csproj
@@ -55,4 +55,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Spice/src/Datasets/RefreshOptions.cs b/Spice/src/Datasets/RefreshOptions.cs
new file mode 100644
index 0000000..5080f27
--- /dev/null
+++ b/Spice/src/Datasets/RefreshOptions.cs
@@ -0,0 +1,179 @@
+/*
+Copyright 2024 The Spice.ai OSS Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+using System.Globalization;
+using System.Text.Json;
+
+namespace Spice.Datasets;
+
+///
+/// The refresh mode to use for a single on-demand dataset refresh.
+///
+///
+/// On-demand refreshes apply to the full and append refresh modes only.
+/// Datasets accelerated with changes mode are kept up to date by change data
+/// capture and are not refreshed through this API.
+///
+public enum RefreshMode
+{
+ ///
+ /// Replace the accelerated data with the full result of the refresh query.
+ ///
+ Full,
+
+ ///
+ /// Append newly returned rows to the accelerated data.
+ ///
+ Append,
+}
+
+///
+/// Optional overrides for a single on-demand dataset acceleration refresh.
+///
+///
+/// Every option is optional. Any option left unset falls back to the value configured
+/// for the dataset in the Spicepod. An instance with no options set requests a refresh
+/// using the dataset's existing configuration.
+///
+///
+///
+/// var options = new RefreshOptions()
+/// .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
+/// .WithRefreshMode(RefreshMode.Append)
+/// .WithMaxJitter(TimeSpan.FromSeconds(10));
+///
+/// await client.RefreshDatasetAsync("taxi_trips", options);
+///
+///
+public sealed class RefreshOptions
+{
+ ///
+ /// The SQL statement used for this refresh. Defaults to the refresh_sql
+ /// configured for the dataset, if any.
+ ///
+ public string? RefreshSql { get; set; }
+
+ ///
+ /// The refresh mode to use for this refresh. Defaults to the refresh_mode
+ /// configured for the dataset, or .
+ ///
+ public RefreshMode? RefreshMode { get; set; }
+
+ ///
+ /// The maximum amount of jitter to add before starting this refresh. Defaults to the
+ /// refresh_jitter_max configured for the dataset, or 10% of the refresh check interval.
+ ///
+ public TimeSpan? MaxJitter { get; set; }
+
+ ///
+ /// Sets the SQL statement used for this refresh.
+ ///
+ /// The refresh SQL statement.
+ /// The current instance of for method chaining.
+ /// Thrown when refreshSql is null or whitespace.
+ public RefreshOptions WithRefreshSql(string refreshSql)
+ {
+#if NET8_0_OR_GREATER
+ ArgumentException.ThrowIfNullOrWhiteSpace(refreshSql);
+#else
+ ThrowHelper.ThrowIfNullOrWhiteSpace(refreshSql, nameof(refreshSql));
+#endif
+ RefreshSql = refreshSql;
+ return this;
+ }
+
+ ///
+ /// Sets the refresh mode to use for this refresh.
+ ///
+ /// The refresh mode.
+ /// The current instance of for method chaining.
+ public RefreshOptions WithRefreshMode(RefreshMode refreshMode)
+ {
+ RefreshMode = refreshMode;
+ return this;
+ }
+
+ ///
+ /// Sets the maximum amount of jitter to add before starting this refresh.
+ ///
+ /// The maximum jitter. Must not be negative.
+ /// The current instance of for method chaining.
+ /// Thrown when maxJitter is negative.
+ public RefreshOptions WithMaxJitter(TimeSpan maxJitter)
+ {
+ if (maxJitter < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxJitter), maxJitter, "maxJitter must not be negative.");
+ }
+
+ MaxJitter = maxJitter;
+ return this;
+ }
+
+ ///
+ /// Serializes the options to the JSON request body expected by the Spice runtime.
+ /// Unset options are omitted so the runtime falls back to the dataset configuration.
+ ///
+ internal string ToJson()
+ {
+ var body = new Dictionary(StringComparer.Ordinal);
+
+ if (!string.IsNullOrWhiteSpace(RefreshSql))
+ {
+ body["refresh_sql"] = RefreshSql!;
+ }
+
+ if (RefreshMode.HasValue)
+ {
+ body["refresh_mode"] = ToWireValue(RefreshMode.Value);
+ }
+
+ if (MaxJitter.HasValue)
+ {
+ body["refresh_jitter_max"] = FormatDuration(MaxJitter.Value);
+ }
+
+ return JsonSerializer.Serialize(body);
+ }
+
+ ///
+ /// Maps a to the value understood by the runtime.
+ ///
+ internal static string ToWireValue(RefreshMode refreshMode) => refreshMode switch
+ {
+ Datasets.RefreshMode.Full => "full",
+ Datasets.RefreshMode.Append => "append",
+ _ => throw new ArgumentOutOfRangeException(nameof(refreshMode), refreshMode, "Unsupported refresh mode."),
+ };
+
+ ///
+ /// Formats a as a duration string the runtime can parse (for example "10s" or "1500ms").
+ ///
+ internal static string FormatDuration(TimeSpan value)
+ {
+ var totalMilliseconds = (long)value.TotalMilliseconds;
+
+ return totalMilliseconds % 1000 == 0
+ ? string.Concat((totalMilliseconds / 1000).ToString(CultureInfo.InvariantCulture), "s")
+ : string.Concat(totalMilliseconds.ToString(CultureInfo.InvariantCulture), "ms");
+ }
+}
diff --git a/Spice/src/Http/ISpiceHttpClient.cs b/Spice/src/Http/ISpiceHttpClient.cs
index 890d61b..4a412fe 100644
--- a/Spice/src/Http/ISpiceHttpClient.cs
+++ b/Spice/src/Http/ISpiceHttpClient.cs
@@ -20,6 +20,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
+using Spice.Datasets;
+
namespace Spice.Http;
///
@@ -44,4 +46,15 @@ public interface ISpiceHttpClient : IDisposable
/// Thrown when datasetName is null or empty
/// Thrown when the HTTP request fails
Task RefreshDatasetAsync(string datasetName);
+
+ ///
+ /// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
+ /// refresh settings for this refresh only.
+ ///
+ /// The name of the dataset to refresh
+ /// Overrides for this refresh, or null to use the dataset configuration
+ /// A task representing the asynchronous operation
+ /// Thrown when datasetName is null or empty
+ /// Thrown when the HTTP request fails
+ Task RefreshDatasetAsync(string datasetName, RefreshOptions? options);
}
diff --git a/Spice/src/Http/SpiceHttpClient.cs b/Spice/src/Http/SpiceHttpClient.cs
index c1902bc..d223336 100644
--- a/Spice/src/Http/SpiceHttpClient.cs
+++ b/Spice/src/Http/SpiceHttpClient.cs
@@ -23,6 +23,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System.Net.Http.Headers;
using System.Text;
using Spice.Auth;
+using Spice.Datasets;
namespace Spice.Http;
@@ -121,7 +122,18 @@ public Task QueryAsync(string sql)
/// A task representing the asynchronous operation
/// Thrown when datasetName is null or empty
/// Thrown when the HTTP request fails
- public async Task RefreshDatasetAsync(string datasetName)
+ public Task RefreshDatasetAsync(string datasetName) => RefreshDatasetAsync(datasetName, null);
+
+ ///
+ /// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
+ /// refresh settings for this refresh only.
+ ///
+ /// The name of the dataset to refresh
+ /// Overrides for this refresh, or null to use the dataset configuration
+ /// A task representing the asynchronous operation
+ /// Thrown when datasetName is null or empty
+ /// Thrown when the HTTP request fails
+ public async Task RefreshDatasetAsync(string datasetName, RefreshOptions? options)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -132,7 +144,8 @@ public async Task RefreshDatasetAsync(string datasetName)
#endif
var url = $"{_httpAddress}/v1/datasets/{datasetName}/acceleration/refresh";
- var response = await _httpClient.PostAsync(url, null).ConfigureAwait(false);
+ using var content = new StringContent(options?.ToJson() ?? "{}", Encoding.UTF8, "application/json");
+ var response = await _httpClient.PostAsync(url, content).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
diff --git a/Spice/src/SpiceClient.cs b/Spice/src/SpiceClient.cs
index 8f3535f..2a8d1f6 100644
--- a/Spice/src/SpiceClient.cs
+++ b/Spice/src/SpiceClient.cs
@@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using Apache.Arrow.Ipc;
using Spice.Adbc;
using Spice.Config;
+using Spice.Datasets;
using Spice.Flight;
using Spice.Http;
@@ -171,13 +172,32 @@ public Task Query(string sql)
}
///
- /// Refreshes a dataset in the Spice runtime.
+ /// Refreshes a dataset in the Spice runtime using the dataset's configured refresh settings.
///
/// The name of the dataset to refresh
/// A task representing the asynchronous operation
/// Thrown when datasetName is null or empty
/// Thrown when the HTTP request fails
- public Task RefreshDatasetAsync(string datasetName)
+ public Task RefreshDatasetAsync(string datasetName) => RefreshDatasetAsync(datasetName, null);
+
+ ///
+ /// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
+ /// refresh settings for this refresh only.
+ ///
+ ///
+ ///
+ /// // Refresh only the recent rows, appending them to the accelerated data.
+ /// await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions()
+ /// .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
+ /// .WithRefreshMode(RefreshMode.Append));
+ ///
+ ///
+ /// The name of the dataset to refresh
+ /// Overrides for this refresh, or null to use the dataset configuration
+ /// A task representing the asynchronous operation
+ /// Thrown when datasetName is null or empty
+ /// Thrown when the HTTP request fails
+ public Task RefreshDatasetAsync(string datasetName, RefreshOptions? options)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -186,7 +206,7 @@ public Task RefreshDatasetAsync(string datasetName)
#endif
if (HttpClient == null) throw new InvalidOperationException("HttpClient not initialized");
- return HttpClient.RefreshDatasetAsync(datasetName);
+ return HttpClient.RefreshDatasetAsync(datasetName, options);
}
private bool _disposed;
diff --git a/SpiceTest/RefreshDatasetRequestTest.cs b/SpiceTest/RefreshDatasetRequestTest.cs
new file mode 100644
index 0000000..e797d1b
--- /dev/null
+++ b/SpiceTest/RefreshDatasetRequestTest.cs
@@ -0,0 +1,157 @@
+/*
+Copyright 2024 The Spice.ai OSS Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using Spice;
+using Spice.Datasets;
+
+namespace SpiceTest;
+
+///
+/// Verifies the HTTP request the client actually puts on the wire for a dataset
+/// refresh, using a loopback as a stand-in runtime.
+/// No Spice runtime or network access is required.
+///
+public class RefreshDatasetRequestTest
+{
+ private sealed record CapturedRequest(string HttpMethod, string Path, string? ContentType, string Body);
+
+ ///
+ /// Runs against a loopback listener and returns the single
+ /// request it received.
+ ///
+ private static async Task CaptureAsync(Func action)
+ {
+ var port = GetFreePort();
+ using var listener = new HttpListener();
+ listener.Prefixes.Add($"http://127.0.0.1:{port}/");
+ listener.Start();
+
+ var serverTask = Task.Run(async () =>
+ {
+ var context = await listener.GetContextAsync().ConfigureAwait(false);
+ using var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8);
+ var body = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ var captured = new CapturedRequest(
+ context.Request.HttpMethod,
+ context.Request.Url!.AbsolutePath,
+ context.Request.ContentType,
+ body);
+
+ context.Response.StatusCode = (int)HttpStatusCode.Created;
+ context.Response.Close();
+ return captured;
+ });
+
+ using (var client = new SpiceClientBuilder()
+ .WithHttpAddress($"http://127.0.0.1:{port}")
+ .Build())
+ {
+ await action(client).ConfigureAwait(false);
+ }
+
+ return await serverTask.ConfigureAwait(false);
+ }
+
+ private static int GetFreePort()
+ {
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ return port;
+ }
+
+ [Test]
+ public async Task Test_RefreshWithOptions_PostsOverridesToAccelerationRefreshEndpoint()
+ {
+ var request = await CaptureAsync(client => client.RefreshDatasetAsync(
+ "taxi_trips",
+ new RefreshOptions()
+ .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
+ .WithRefreshMode(RefreshMode.Append)
+ .WithMaxJitter(TimeSpan.FromSeconds(10))));
+
+ var body = JsonSerializer.Deserialize>(request.Body)!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(request.HttpMethod, Is.EqualTo("POST"));
+ Assert.That(request.Path, Is.EqualTo("/v1/datasets/taxi_trips/acceleration/refresh"));
+ Assert.That(request.ContentType, Does.Contain("application/json"));
+ Assert.That(body["refresh_sql"], Is.EqualTo("SELECT * FROM taxi_trips WHERE tip_amount > 10.0"));
+ Assert.That(body["refresh_mode"], Is.EqualTo("append"));
+ Assert.That(body["refresh_jitter_max"], Is.EqualTo("10s"));
+ });
+ }
+
+ [Test]
+ public async Task Test_RefreshWithoutOptions_PostsEmptyJsonObject()
+ {
+ var request = await CaptureAsync(client => client.RefreshDatasetAsync("taxi_trips"));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(request.HttpMethod, Is.EqualTo("POST"));
+ Assert.That(request.Path, Is.EqualTo("/v1/datasets/taxi_trips/acceleration/refresh"));
+ Assert.That(request.ContentType, Does.Contain("application/json"));
+ Assert.That(request.Body, Is.EqualTo("{}"));
+ });
+ }
+
+ [Test]
+ public async Task Test_RefreshWithNullOptions_PostsEmptyJsonObject()
+ {
+ var request = await CaptureAsync(client => client.RefreshDatasetAsync("taxi_trips", null));
+
+ Assert.That(request.Body, Is.EqualTo("{}"));
+ }
+
+ [Test]
+ public async Task Test_RefreshWithPartialOptions_OmitsUnsetFields()
+ {
+ var request = await CaptureAsync(client => client.RefreshDatasetAsync(
+ "taxi_trips",
+ new RefreshOptions().WithRefreshMode(RefreshMode.Full)));
+
+ var body = JsonSerializer.Deserialize>(request.Body)!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Has.Count.EqualTo(1));
+ Assert.That(body["refresh_mode"], Is.EqualTo("full"));
+ });
+ }
+
+ [Test]
+ public void Test_RefreshWithBlankDatasetName_Throws()
+ {
+ using var client = new SpiceClientBuilder().Build();
+
+ Assert.ThrowsAsync(
+ async () => await client.RefreshDatasetAsync(" ", new RefreshOptions()));
+ }
+}
diff --git a/SpiceTest/RefreshOptionsTest.cs b/SpiceTest/RefreshOptionsTest.cs
new file mode 100644
index 0000000..df18462
--- /dev/null
+++ b/SpiceTest/RefreshOptionsTest.cs
@@ -0,0 +1,163 @@
+/*
+Copyright 2024 The Spice.ai OSS Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+using System.Text.Json;
+using Spice.Datasets;
+
+namespace SpiceTest;
+
+///
+/// Unit tests for and the JSON body it produces.
+/// The wire format is shared with the other Spice SDKs: the runtime accepts
+/// refresh_sql, refresh_mode and refresh_jitter_max on
+/// POST /v1/datasets/{name}/acceleration/refresh.
+///
+public class RefreshOptionsTest
+{
+ private static Dictionary Deserialize(string json) =>
+ JsonSerializer.Deserialize>(json)!;
+
+ [Test]
+ public void Test_EmptyOptions_SerializeToEmptyObject()
+ {
+ var json = new RefreshOptions().ToJson();
+
+ Assert.That(json, Is.EqualTo("{}"));
+ }
+
+ [Test]
+ public void Test_AllOptions_SerializeToRuntimeFieldNames()
+ {
+ var json = new RefreshOptions()
+ .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
+ .WithRefreshMode(RefreshMode.Append)
+ .WithMaxJitter(TimeSpan.FromSeconds(10))
+ .ToJson();
+
+ var body = Deserialize(json);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body["refresh_sql"], Is.EqualTo("SELECT * FROM taxi_trips WHERE tip_amount > 10.0"));
+ Assert.That(body["refresh_mode"], Is.EqualTo("append"));
+ Assert.That(body["refresh_jitter_max"], Is.EqualTo("10s"));
+ Assert.That(body, Has.Count.EqualTo(3));
+ });
+ }
+
+ [Test]
+ public void Test_UnsetOptions_AreOmitted()
+ {
+ var json = new RefreshOptions().WithRefreshMode(RefreshMode.Full).ToJson();
+
+ var body = Deserialize(json);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Has.Count.EqualTo(1));
+ Assert.That(body["refresh_mode"], Is.EqualTo("full"));
+ Assert.That(body.ContainsKey("refresh_sql"), Is.False);
+ Assert.That(body.ContainsKey("refresh_jitter_max"), Is.False);
+ });
+ }
+
+ [Test]
+ public void Test_ObjectInitializerSyntax_IsEquivalentToBuilder()
+ {
+ var builder = new RefreshOptions()
+ .WithRefreshSql("SELECT 1")
+ .WithRefreshMode(RefreshMode.Full)
+ .WithMaxJitter(TimeSpan.FromMilliseconds(1500))
+ .ToJson();
+
+ var initializer = new RefreshOptions
+ {
+ RefreshSql = "SELECT 1",
+ RefreshMode = RefreshMode.Full,
+ MaxJitter = TimeSpan.FromMilliseconds(1500),
+ }.ToJson();
+
+ Assert.That(initializer, Is.EqualTo(builder));
+ }
+
+ [Test]
+ public void Test_RefreshSqlWithQuotes_IsJsonEscaped()
+ {
+ // Refresh SQL is user-supplied and must not be able to break the JSON body.
+ const string sql = "SELECT * FROM t WHERE name = 'O\"Brien' AND note = 'a\\b'";
+
+ var json = new RefreshOptions().WithRefreshSql(sql).ToJson();
+
+ Assert.That(Deserialize(json)["refresh_sql"], Is.EqualTo(sql));
+ }
+
+ [TestCase(0, "0s")]
+ [TestCase(1000, "1s")]
+ [TestCase(10000, "10s")]
+ [TestCase(1500, "1500ms")]
+ [TestCase(250, "250ms")]
+ [TestCase(90000, "90s")]
+ public void Test_MaxJitter_FormatsAsRuntimeDuration(int milliseconds, string expected)
+ {
+ var json = new RefreshOptions().WithMaxJitter(TimeSpan.FromMilliseconds(milliseconds)).ToJson();
+
+ Assert.That(Deserialize(json)["refresh_jitter_max"], Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Test_RefreshMode_MapsToLowercaseWireValues()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(RefreshOptions.ToWireValue(RefreshMode.Full), Is.EqualTo("full"));
+ Assert.That(RefreshOptions.ToWireValue(RefreshMode.Append), Is.EqualTo("append"));
+ });
+ }
+
+ [Test]
+ public void Test_NegativeMaxJitter_Throws()
+ {
+ Assert.Throws(
+ () => new RefreshOptions().WithMaxJitter(TimeSpan.FromSeconds(-1)));
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ public void Test_BlankRefreshSql_Throws(string refreshSql)
+ {
+ Assert.Throws(() => new RefreshOptions().WithRefreshSql(refreshSql));
+ }
+
+ [Test]
+ public void Test_NullRefreshSql_Throws()
+ {
+ Assert.Throws(() => new RefreshOptions().WithRefreshSql(null!));
+ }
+
+ [Test]
+ public void Test_WhitespaceRefreshSqlProperty_IsOmittedRatherThanSentBlank()
+ {
+ var json = new RefreshOptions { RefreshSql = " " }.ToJson();
+
+ Assert.That(json, Is.EqualTo("{}"));
+ }
+}