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("{}")); + } +}