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
+
+