diff --git a/Realtime/ClientOptions.cs b/Realtime/ClientOptions.cs index b5cf515..c5bd2a0 100644 --- a/Realtime/ClientOptions.cs +++ b/Realtime/ClientOptions.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; using Newtonsoft.Json; +using Supabase.Postgrest.Interfaces; using Supabase.Realtime.Socket; namespace Supabase.Realtime; @@ -70,4 +71,16 @@ public class ClientOptions /// Datetime format for JSON Deserialization of Models (Postgrest style) /// public string DateTimeFormat { get; set; } = @"yyyy'-'MM'-'dd' 'HH':'mm':'ss.FFFFFFK"; + + /// + /// Optional Postgrest client. When set, every model returned by + /// and + /// OldModel<TModel> has this client's context (BaseUrl, RequestClientOptions, headers) attached to + /// it via Attach<T>() - enabling Update/Delete to be called directly on a model + /// received from Realtime. + /// + /// Left null, Realtime remains usable standalone: models still deserialize normally, just without client context. + /// The Supabase umbrella client wires this automatically to its own Postgrest client. + /// + public IPostgrestClient? PostgrestClient { get; set; } } \ No newline at end of file diff --git a/Realtime/PostgresChanges/PostgresChangesResponse.cs b/Realtime/PostgresChanges/PostgresChangesResponse.cs index 2cf44f1..67ab0fd 100644 --- a/Realtime/PostgresChanges/PostgresChangesResponse.cs +++ b/Realtime/PostgresChanges/PostgresChangesResponse.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Newtonsoft.Json; +using Supabase.Postgrest.Interfaces; using Supabase.Postgrest.Models; using Supabase.Realtime.Socket; @@ -24,6 +25,13 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base { } + /// + /// Postgrest client set by from + /// , used to attach its context to models + /// returned by /. + /// + internal IPostgrestClient? PostgrestClient { get; set; } + /// /// Hydrates the referenced record into a Model (if possible). /// @@ -34,7 +42,10 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base if (Json != null && Payload != null && Payload.Data?.Record != null) { var response = JsonConvert.DeserializeObject>(Json, SerializerSettings); - return response?.Payload?.Data?.Record; + var model = response?.Payload?.Data?.Record; + if (model != null) + PostgrestClient?.Attach(model); + return model; } else { @@ -44,7 +55,7 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base /// /// Hydrates the old_record into a Model (if possible). - /// + /// /// NOTE: If you want to receive the "previous" data for updates and deletes, you will need to set `REPLICA IDENTITY to FULL`, like this: `ALTER TABLE your_table REPLICA IDENTITY FULL`; /// /// @@ -54,7 +65,10 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base if (Json != null && Payload != null && Payload.Data?.OldRecord != null) { var response = JsonConvert.DeserializeObject>(Json, SerializerSettings); - return response?.Payload?.Data?.OldRecord; + var model = response?.Payload?.Data?.OldRecord; + if (model != null) + PostgrestClient?.Attach(model); + return model; } else { diff --git a/Realtime/Realtime.csproj b/Realtime/Realtime.csproj index 45a777f..cf5e7c9 100644 --- a/Realtime/Realtime.csproj +++ b/Realtime/Realtime.csproj @@ -46,7 +46,7 @@ - + diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index c38ea8f..4d12390 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -740,6 +740,7 @@ internal void HandleSocketMessage(SocketResponse message) deserialized.Json = message.Json; deserialized.SerializerSettings = Options.SerializerSettings; + deserialized.PostgrestClient = Options.ClientOptions.PostgrestClient; // Invoke '*' listener NotifyPostgresChanges(deserialized.Payload!.Data!.Type, deserialized); diff --git a/RealtimeTests/Fixtures/PostgresChangesUpdateEvent.json b/RealtimeTests/Fixtures/PostgresChangesUpdateEvent.json new file mode 100644 index 0000000..054c2fd --- /dev/null +++ b/RealtimeTests/Fixtures/PostgresChangesUpdateEvent.json @@ -0,0 +1,17 @@ +{ + "topic": "realtime:public:todos", + "event": "postgres_changes", + "payload": { + "data": { + "columns": [{"name": "id", "type": "int8"}, {"name": "details", "type": "text"}], + "commit_timestamp": "2023-09-11T15:30:21+00:00", + "schema": "public", + "table": "todos", + "type": "UPDATE", + "old_record": {"id": 12, "details": "previous", "user_id": 1}, + "record": {"id": 12, "details": "test...", "user_id": 1} + }, + "ids": [1] + }, + "ref": null +} diff --git a/RealtimeTests/PostgresChangesResponseAttachTests.cs b/RealtimeTests/PostgresChangesResponseAttachTests.cs new file mode 100644 index 0000000..46ff8a5 --- /dev/null +++ b/RealtimeTests/PostgresChangesResponseAttachTests.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using RealtimeTests.Models; +using Supabase.Postgrest.Exceptions; +using Supabase.Postgrest.Interfaces; +using Supabase.Realtime.PostgresChanges; + +namespace RealtimeTests; + +/// +/// Feature under test: realtime-csharp#35 - a model returned by PostgresChangesResponse.Model<TModel>/ +/// OldModel<TModel> gets a configured 's +/// context attached to it (via Attach<T>()), so that Update/Delete can be called +/// directly on it. Reproduces the exact deserialization path RealtimeChannel.HandleSocketMessage() uses for +/// postgres_changes events, without needing a live socket connection. +/// +/// Split into two groups: +/// - no PostgrestClient configured: the default/standalone case, which must stay backward-compatible. +/// - a PostgrestClient configured: the case the Supabase umbrella client wires up automatically. +/// +[TestClass] +public class PostgresChangesResponseAttachTests +{ + private const string BaseUrl = "http://localhost:54321/rest/v1"; + + private static PostgresChangesResponse BuildResponse(IPostgrestClient? postgrestClient) + { + var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "PostgresChangesUpdateEvent.json")); + var serializerSettings = Supabase.Postgrest.Client.SerializerSettings(); + + // Mirrors RealtimeChannel.HandleSocketMessage() exactly: deserialize, then stamp Json, SerializerSettings, + // and PostgrestClient for the later Model() re-deserialization pass. + var deserialized = JsonConvert.DeserializeObject(json, serializerSettings); + deserialized!.Json = json; + deserialized.SerializerSettings = serializerSettings; + deserialized.PostgrestClient = postgrestClient; + return deserialized; + } + + private static void AssertDoesNotThrowBaseUrlException(Exception ex) + { + Assert.IsFalse(ex is PostgrestException { Message: var m } && m.Contains("should be set in the model"), + $"Unexpectedly got the BaseUrl exception: {ex}"); + } + + [TestMethod(DisplayName = "Without a PostgrestClient, Model() leaves BaseUrl/RequestClientOptions null")] + public void GivenNoPostgrestClient_Model_LeavesClientContextNull() + { + var response = BuildResponse(postgrestClient: null); + var model = response.Model(); + + Assert.IsNotNull(model, "Sanity check: the model itself should deserialize correctly."); + Assert.AreEqual(12, model!.Id); + Assert.IsNull(model.BaseUrl); + Assert.IsNull(model.RequestClientOptions); + } + + [TestMethod(DisplayName = "Without a PostgrestClient, Update() throws PostgrestException (loud, not silent)")] + public async Task GivenNoPostgrestClient_Update_ThrowsPostgrestException() + { + var response = BuildResponse(postgrestClient: null); + var model = response.Model()!; + + var ex = await Assert.ThrowsAsync(() => model.Update()); + StringAssert.Contains(ex.Message, "BaseUrl"); + } + + [TestMethod(DisplayName = "Without a PostgrestClient, Delete() throws PostgrestException (loud, not silent)")] + public async Task GivenNoPostgrestClient_Delete_ThrowsPostgrestException() + { + var response = BuildResponse(postgrestClient: null); + var model = response.Model()!; + + var ex = await Assert.ThrowsAsync(() => model.Delete()); + StringAssert.Contains(ex.Message, "BaseUrl"); + } + + [TestMethod(DisplayName = "With a PostgrestClient configured, Model() attaches BaseUrl/RequestClientOptions")] + public void GivenPostgrestClient_Model_AttachesClientContext() + { + var response = BuildResponse(new Supabase.Postgrest.Client(BaseUrl)); + var model = response.Model()!; + + Assert.AreEqual(BaseUrl, model.BaseUrl); + Assert.IsNotNull(model.RequestClientOptions); + } + + [TestMethod(DisplayName = "With a PostgrestClient configured, Update() does not throw the BaseUrl exception")] + public async Task GivenPostgrestClient_Update_DoesNotThrowBaseUrlException() + { + var response = BuildResponse(new Supabase.Postgrest.Client(BaseUrl)); + var model = response.Model()!; + + try + { + await model.Update(); + } + catch (Exception ex) + { + AssertDoesNotThrowBaseUrlException(ex); + } + } + + [TestMethod(DisplayName = "With a PostgrestClient configured, Delete() does not throw the BaseUrl exception")] + public async Task GivenPostgrestClient_Delete_DoesNotThrowBaseUrlException() + { + var response = BuildResponse(new Supabase.Postgrest.Client(BaseUrl)); + var model = response.Model()!; + + try + { + await model.Delete(); + } + catch (Exception ex) + { + AssertDoesNotThrowBaseUrlException(ex); + } + } +} diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index 0f72067..665f926 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -20,4 +20,10 @@ + + + + PreserveNewest + +