Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Realtime/ClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Diagnostics;
using System.Globalization;
using Newtonsoft.Json;
using Supabase.Postgrest.Interfaces;
using Supabase.Realtime.Socket;

namespace Supabase.Realtime;
Expand Down Expand Up @@ -70,4 +71,16 @@ public class ClientOptions
/// Datetime format for JSON Deserialization of Models (Postgrest style)
/// </summary>
public string DateTimeFormat { get; set; } = @"yyyy'-'MM'-'dd' 'HH':'mm':'ss.FFFFFFK";

/// <summary>
/// Optional Postgrest client. When set, every model returned by
/// <see cref="Supabase.Realtime.PostgresChanges.PostgresChangesResponse.Model{TModel}"/> and
/// <c>OldModel&lt;TModel&gt;</c> has this client's context (BaseUrl, RequestClientOptions, headers) attached to
/// it via <c>Attach&lt;T&gt;()</c> - enabling <c>Update</c>/<c>Delete</c> 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.
/// </summary>
public IPostgrestClient? PostgrestClient { get; set; }
}
20 changes: 17 additions & 3 deletions Realtime/PostgresChanges/PostgresChangesResponse.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Supabase.Postgrest.Interfaces;
using Supabase.Postgrest.Models;
using Supabase.Realtime.Socket;

Expand All @@ -24,6 +25,13 @@
{
}

/// <summary>
/// Postgrest client set by <see cref="Supabase.Realtime.RealtimeChannel"/> from
/// <see cref="Supabase.Realtime.ClientOptions.PostgrestClient"/>, used to attach its context to models
/// returned by <see cref="Model{TModel}"/>/<see cref="OldModel{TModel}"/>.
/// </summary>
internal IPostgrestClient? PostgrestClient { get; set; }

/// <summary>
/// Hydrates the referenced record into a Model (if possible).
/// </summary>
Expand All @@ -34,7 +42,10 @@
if (Json != null && Payload != null && Payload.Data?.Record != null)
{
var response = JsonConvert.DeserializeObject<PostgresChangesResponse<TModel>>(Json, SerializerSettings);
return response?.Payload?.Data?.Record;
var model = response?.Payload?.Data?.Record;
if (model != null)
PostgrestClient?.Attach(model);
return model;
}
else
{
Expand All @@ -44,7 +55,7 @@

/// <summary>
/// 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`;
/// </summary>
/// <typeparam name="TModel"></typeparam>
Expand All @@ -54,7 +65,10 @@
if (Json != null && Payload != null && Payload.Data?.OldRecord != null)
{
var response = JsonConvert.DeserializeObject<PostgresChangesResponse<TModel>>(Json, SerializerSettings);
return response?.Payload?.Data?.OldRecord;
var model = response?.Payload?.Data?.OldRecord;
if (model != null)
PostgrestClient?.Attach(model);
return model;
}
else
{
Expand All @@ -76,4 +90,4 @@
public SocketResponsePayload<T>? Data { get; set; }

[JsonProperty("ids")]
public List<int?> Ids { get; set; }

Check warning on line 93 in Realtime/PostgresChanges/PostgresChangesResponse.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Non-nullable property 'Ids' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 93 in Realtime/PostgresChanges/PostgresChangesResponse.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Non-nullable property 'Ids' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
Expand Down
2 changes: 1 addition & 1 deletion Realtime/Realtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="Supabase.Core" Version="1.1.0" />
<PackageReference Include="Supabase.Postgrest" Version="4.2.0" />
<PackageReference Include="Supabase.Postgrest" Version="4.3.0" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
Expand Down
1 change: 1 addition & 0 deletions Realtime/RealtimeChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions RealtimeTests/Fixtures/PostgresChangesUpdateEvent.json
Original file line number Diff line number Diff line change
@@ -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
}
122 changes: 122 additions & 0 deletions RealtimeTests/PostgresChangesResponseAttachTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Feature under test: realtime-csharp#35 - a model returned by <c>PostgresChangesResponse.Model&lt;TModel&gt;</c>/
/// <c>OldModel&lt;TModel&gt;</c> gets a configured <see cref="Supabase.Realtime.ClientOptions.PostgrestClient"/>'s
/// context attached to it (via <c>Attach&lt;T&gt;()</c>), so that <c>Update</c>/<c>Delete</c> 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.
/// </summary>
[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<T>() re-deserialization pass.
var deserialized = JsonConvert.DeserializeObject<PostgresChangesResponse>(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<T>() leaves BaseUrl/RequestClientOptions null")]
public void GivenNoPostgrestClient_Model_LeavesClientContextNull()
{
var response = BuildResponse(postgrestClient: null);
var model = response.Model<Todo>();

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<Todo>()!;

var ex = await Assert.ThrowsAsync<PostgrestException>(() => model.Update<Todo>());
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<Todo>()!;

var ex = await Assert.ThrowsAsync<PostgrestException>(() => model.Delete<Todo>());
StringAssert.Contains(ex.Message, "BaseUrl");
}

[TestMethod(DisplayName = "With a PostgrestClient configured, Model<T>() attaches BaseUrl/RequestClientOptions")]
public void GivenPostgrestClient_Model_AttachesClientContext()
{
var response = BuildResponse(new Supabase.Postgrest.Client(BaseUrl));
var model = response.Model<Todo>()!;

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<Todo>()!;

try
{
await model.Update<Todo>();
}
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<Todo>()!;

try
{
await model.Delete<Todo>();
}
catch (Exception ex)
{
AssertDoesNotThrowBaseUrlException(ex);
}
}
}
6 changes: 6 additions & 0 deletions RealtimeTests/RealtimeTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@
<ItemGroup>
<ProjectReference Include="..\Realtime\Realtime.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="Fixtures\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
Loading