From 5e3c491ad142c7902bd120f10f5952f6393ddb2e Mon Sep 17 00:00:00 2001 From: Diego Santo Date: Fri, 21 Aug 2026 21:18:07 -0300 Subject: [PATCH 1/2] feat(realtime): add filter like postgrest parity --- .../Filter/PostgresFilterBuilderTests.cs | 289 ++++++++++++++++++ .../PostgresChangesDeliveryTests.cs | 17 +- .../Filter/PostgresChangesFilterOperator.cs | 88 ++++++ .../Filter/PostgresFilterBuilder.cs | 231 ++++++++++++++ .../Filter/PostgresFilterValue.cs | 163 ++++++++++ 5 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs create mode 100644 packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs create mode 100644 packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterBuilder.cs create mode 100644 packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs diff --git a/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs b/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs new file mode 100644 index 00000000..153a64e4 --- /dev/null +++ b/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Realtime.PostgresChanges.Filter; + +namespace Realtime.Tests.PostgresChanges.Filter; + +[TestClass] +[TestCategory("Unit")] +public class PostgresFilterBuilderTests +{ + [TestMethod] + [DataRow("test", "test")] + [DataRow("\"test\"", "\"\\\"test\\\"\"")] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + [DataRow(true, "true")] + [DataRow("Hello, World", "\"Hello, World\"")] + public void Filter_ShouldCreateEqual_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Eq; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Eq(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void Filter_ShouldCreateEqualChained_GivenInput() + { + const string column1 = "user"; + const string column2 = "account"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Eq; + var expected = + $"{column1}={filterOperator.ToMappedString()}.test,{column2}={filterOperator.ToMappedString()}.test"; + + var result = PostgresFilterBuilder + .Builder() + .Eq(column1, "test") + .Eq(column2, "test") + .Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("test", "test")] + [DataRow("\"test\"", "\"\\\"test\\\"\"")] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + [DataRow(true, "true")] + [DataRow("Hello, World", "\"Hello, World\"")] + public void Filter_ShouldCreateNotEqual_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Neq; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Neq(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + public void Filter_ShouldCreateLt_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Lt; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Lt(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + public void Filter_ShouldCreateLte_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Lte; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Lte(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + public void Filter_ShouldCreateGt_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Gt; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Gt(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + public void Filter_ShouldCreateGte_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Gte; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Gte(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(new object[] { "1", "2", "3", "4" }, "(1,2,3,4)")] + [DataRow(new object[] { "hello", "world" }, "(hello,world)")] + [DataRow(new object[] { "hello, world", "world" }, "(\"hello, world\",world)")] + [DataRow(new object[] { 1, 2, 3, 4 }, "(1,2,3,4)")] + public void Filter_ShouldCreateIn_GivenInput(IEnumerable value, string expect) + { + const string column = "description"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.In; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().In(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("test%", "test%")] + [DataRow("\"%test\"", "\"\\\"%test\\\"\"")] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + [DataRow(true, "true")] + [DataRow("%Hello, World%", "\"%Hello, World%\"")] + public void Filter_ShouldCreateLike_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Like; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Like(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("test%", "test%")] + [DataRow("\"%test\"", "\"\\\"%test\\\"\"")] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + [DataRow(true, "true")] + [DataRow("%Hello, World%", "\"%Hello, World%\"")] + public void Filter_ShouldCreateILike_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.ILike; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().ILike(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("test%", "test%")] + [DataRow("\"%test\"", "\"\\\"%test\\\"\"")] + [DataRow(2, "2")] + [DataRow(2D, "2")] + [DataRow(2F, "2")] + [DataRow(true, "true")] + [DataRow("%Hello, World%", "\"%Hello, World%\"")] + public void Filter_ShouldCreateNot_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.ILike; + var expected = $"{column}=not.{filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Not(column, filterOperator, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(new object[] { "1", "2", "3", "4" }, "(1,2,3,4)")] + [DataRow(new object[] { "hello", "world" }, "(hello,world)")] + [DataRow(new object[] { "hello, world", "world" }, "(\"hello, world\",world)")] + [DataRow(new object[] { 1, 2, 3, 4 }, "(1,2,3,4)")] + public void Filter_ShouldCreateNotIn_GivenInput(IEnumerable value, string expect) + { + const string column = "description"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.In; + var expected = $"{column}=not.{filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Not(column, filterOperator, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("null", "null")] + [DataRow(null, "null")] + [DataRow(true, "true")] + [DataRow(false, "false")] + public void Filter_ShouldCreateIs_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Is; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Is(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow("null", "null")] + [DataRow(null, "null")] + [DataRow(true, "true")] + [DataRow(false, "false")] + public void Filter_ShouldCreateNotIs_GivenInput(object value, string expect) + { + const string column = "user"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.Is; + var expected = $"{column}=not.{filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().Not(column, filterOperator, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void Filter_ShouldThrow_GivenInWithString() + { + Assert.Throws(() => + PostgresFilterBuilder + .Builder() + .Not("status", PostgresChangesFilterOperator.In, "open") + .Build() + ); + } + + [TestMethod] + public void Filter_ShouldThrow_GivenInWithEmptyCollection() + { + Assert.Throws(() => + PostgresFilterBuilder.Builder().In("status", Array.Empty()).Build() + ); + } + + [TestMethod] + public void Filter_ShouldThrow_GivenIsWithInvalidString() + { + Assert.Throws(() => + PostgresFilterBuilder.Builder().Is("status", "open").Build() + ); + } +} diff --git a/packages/Realtime/Realtime.Tests/PostgresChanges/PostgresChangesDeliveryTests.cs b/packages/Realtime/Realtime.Tests/PostgresChanges/PostgresChangesDeliveryTests.cs index b19211a1..77e377e0 100644 --- a/packages/Realtime/Realtime.Tests/PostgresChanges/PostgresChangesDeliveryTests.cs +++ b/packages/Realtime/Realtime.Tests/PostgresChanges/PostgresChangesDeliveryTests.cs @@ -8,6 +8,7 @@ using Supabase.Realtime; using Supabase.Realtime.Interfaces; using Supabase.Realtime.PostgresChanges; +using Supabase.Realtime.PostgresChanges.Filter; using static Supabase.Realtime.Constants; using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; @@ -68,12 +69,13 @@ public async Task OnPostgresChange_ShouldReceiveFilteredInsert() { var tsc = new TaskCompletionSource(); var channel = socketClient.Channel("realtime", "public", "todos"); + var filter = PostgresFilterBuilder.Builder().Eq("details", "Client receives filtered insert callback? ✅"); channel.OnPostgresChange((_, changes) => { Assert.AreEqual("Client receives filtered insert callback? ✅", changes.Model()?.Details); tsc.SetResult(true); }, ListenType.Inserts, - new PostgresChangesFilter { Table = "todos", Filter = "details=eq.Client receives filtered insert callback? ✅" }); + new PostgresChangesFilter { Table = "todos", Filter = filter.Build() }); await channel.Subscribe(); await restClient.Table().Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); await restClient.Table().Insert(new Todo { UserId = 2, Details = "Client receives filtered insert callback? ✅" }); @@ -105,11 +107,12 @@ await restClient.Table() tsc.SetResult(true); }, ListenType.Updates, new PostgresChangesFilter { Table = "todos" }); const string filter = "Client receives filtered insert callback? ✅"; + var filterBuilder = PostgresFilterBuilder.Builder().Eq("details", filter); channel.OnPostgresChange((_, changes) => { Assert.AreEqual("Client receives filtered insert callback? ✅", changes.Model()?.Details); tsc.SetResult(true); - }, ListenType.Inserts, new PostgresChangesFilter { Table = "todos", Filter = $"details=eq.{filter}" }); + }, ListenType.Inserts, new PostgresChangesFilter { Table = "todos", Filter = filterBuilder.Build() }); await channel.Subscribe(); await restClient.Table().Set(x => x.Details!, newDetails).Match(model).Update(); Assert.IsTrue(await tsc.Task); @@ -164,6 +167,7 @@ public async Task OnPostgresChange_ShouldReceiveFilteredDelete() var todo1 = await restClient.Table().Insert(new Todo { UserId = 1, Details = "Client receives callbacks 1? ✅" }); var todo2 = await restClient.Table().Insert(new Todo { UserId = 2, Details = "Client receives callbacks 2? ✅" }); await restClient.Table().Insert(new Todo { UserId = 3, Details = "Client receives callbacks 3? ✅" }); + var filterBuilder = PostgresFilterBuilder.Builder().Eq("details", todo1.Model?.Details); channel.OnPostgresChange((_, removed) => { var result = removed.OldModel(); @@ -171,7 +175,7 @@ public async Task OnPostgresChange_ShouldReceiveFilteredDelete() Assert.AreNotEqual(result?.Details, todo2.Model?.Details); tsc.SetResult(true); }, ListenType.Deletes, - new PostgresChangesFilter { Table = "todos", Filter = $"details=eq.{todo1.Model?.Details}" }); + new PostgresChangesFilter { Table = "todos", Filter = filterBuilder.Build() }); await channel.Subscribe(); await restClient.Table().Match(todo1.Models.First()).Delete(); await restClient.Table().Match(todo2.Models.First()).Delete(); @@ -213,6 +217,9 @@ public async Task OnPostgresChange_ShouldFanOutToMultipleInsertListeners() var insertTask3 = new TaskCompletionSource(); const string filter1 = "Client receives callbacks 1? ✅"; const string filter2 = "Client receives callbacks 2? ✅"; + var filterBuilder1 = PostgresFilterBuilder.Builder().Eq("details", filter1).Build(); + var filterBuilder2 = PostgresFilterBuilder.Builder().Eq("details", filter2).Build(); + var channel = socketClient.Channel("realtime", "public", "todos"); var count = 0; channel.OnPostgresChange((_, _) => @@ -222,10 +229,10 @@ public async Task OnPostgresChange_ShouldFanOutToMultipleInsertListeners() }, ListenType.Inserts, new PostgresChangesFilter { Table = "todos" }); channel.OnPostgresChange((_, added) => insertTask2.SetResult(added.Model()?.Details == filter1), ListenType.Inserts, - new PostgresChangesFilter { Table = "todos", Filter = $"details=eq.{filter1}" }); + new PostgresChangesFilter { Table = "todos", Filter = filterBuilder1 }); channel.OnPostgresChange((_, added) => insertTask3.SetResult(added.Model()?.Details == filter2), ListenType.Inserts, - new PostgresChangesFilter { Table = "todos", Filter = $"details=eq.{filter2}" }); + new PostgresChangesFilter { Table = "todos", Filter = filterBuilder2 }); await channel.Subscribe(); await restClient.Table().Insert(new Todo { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); await restClient.Table().Insert(new Todo { UserId = 1, Details = filter1 }); diff --git a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs new file mode 100644 index 00000000..b5addd05 --- /dev/null +++ b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Reflection; +using Supabase.Core.Attributes; + +namespace Supabase.Realtime.PostgresChanges.Filter; + +/// +/// Filter operators for PostgreSQL realtime changes. These operators are used in +/// to specify comparison conditions on column values +/// when subscribing to database changes via realtime channels. +/// +public enum PostgresChangesFilterOperator +{ + /// Equal to. + [MapTo("eq")] + Eq, + + /// Not equal to. + [MapTo("neq")] + Neq, + + /// Less than. + [MapTo("lt")] + Lt, + + /// Less than or equal to. + [MapTo("lte")] + Lte, + + /// Greater than. + [MapTo("gt")] + Gt, + + /// Greater than or equal to. + [MapTo("gte")] + Gte, + + /// Value is in a set. + [MapTo("in")] + In, + + /// Case-sensitive pattern matching. + [MapTo("like")] + Like, + + /// Case-insensitive pattern matching. + [MapTo("ilike")] + ILike, + + /// Exact match (for null/boolean). + [MapTo("is")] + Is, + + /// Case-sensitive regular expression match. + [MapTo("match")] + Match, + + /// Case-insensitive regular expression match. + [MapTo("imatch")] + IMatch, + + /// Value is distinct from. + [MapTo("is_distinct")] + IsDistinct, +} + +/// +/// Extension methods for . +/// +public static class PostgresChangesFilterOperatorExtensions +{ + /// + /// Converts the enum value to its protocol string + /// representation using the mapping. + /// + /// The filter operator to convert. + /// The mapped string value (e.g., "eq", "neq") or the enum name if no mapping exists. + public static string ToMappedString(this PostgresChangesFilterOperator filterOperator) + { + var member = typeof(PostgresChangesFilterOperator) + .GetMember(filterOperator.ToString()) + .FirstOrDefault(); + + var attribute = member?.GetCustomAttribute(); + + return attribute?.Mapping ?? filterOperator.ToString(); + } +} diff --git a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterBuilder.cs b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterBuilder.cs new file mode 100644 index 00000000..cb0750b7 --- /dev/null +++ b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterBuilder.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; + +namespace Supabase.Realtime.PostgresChanges.Filter; + +/// +/// A builder for constructing filters for Postgres changes subscriptions. +/// This filter syntax is similar to PostgREST Supabase filters and allows +/// filtering realtime database events based on column values. +/// +public class PostgresFilterBuilder +{ + /// + /// Internal list storing the individual filter expressions. + /// + private readonly List filters = []; + + /// + /// Private constructor to enforce the builder pattern. + /// Use to create instances. + /// + private PostgresFilterBuilder() { } + + /// + /// Creates a new instance of the PostgresFilterBuilder. + /// + /// A new PostgresFilterBuilder instance. + public static PostgresFilterBuilder Builder() => new(); + + /// + /// Adds a filter expression to the builder. + /// + /// The column name to filter on. + /// The filter operator to apply. + /// The value to compare against. + /// Whether to negate the filter expression. + /// Thrown when the column is null or empty. + private void Add( + string column, + PostgresChangesFilterOperator filterOperator, + object? value, + bool negate = false + ) + { + if (string.IsNullOrEmpty(column)) + { + throw new ArgumentException("Column cannot be null or empty.", nameof(column)); + } + + var prefix = negate ? "not." : ""; + var filterValue = new PostgresFilterValue(filterOperator, value); + this.filters.Add( + $"{column}={prefix}{filterOperator.ToMappedString()}.{filterValue.Value}" + ); + } + + /// + /// Adds an equality filter (column = value). + /// + /// The column name to filter on. + /// The value to compare for equality. + /// The current builder instance for chaining. + public PostgresFilterBuilder Eq(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Eq, value); + return this; + } + + /// + /// Adds a not-equal filter (column != value). + /// + /// The column name to filter on. + /// The value to compare for inequality. + /// The current builder instance for chaining. + public PostgresFilterBuilder Neq(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Neq, value); + return this; + } + + /// + /// Adds a less-than filter (column < value). + /// + /// The column name to filter on. + /// The value to compare against. + /// The current builder instance for chaining. + public PostgresFilterBuilder Lt(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Lt, value); + return this; + } + + /// + /// Adds a less-than-or-equal filter (column <= value). + /// + /// The column name to filter on. + /// The value to compare against. + /// The current builder instance for chaining. + public PostgresFilterBuilder Lte(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Lte, value); + return this; + } + + /// + /// Adds a greater-than filter (column > value). + /// + /// The column name to filter on. + /// The value to compare against. + /// The current builder instance for chaining. + public PostgresFilterBuilder Gt(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Gt, value); + return this; + } + + /// + /// Adds a greater-than-or-equal filter (column >= value). + /// + /// The column name to filter on. + /// The value to compare against. + /// The current builder instance for chaining. + public PostgresFilterBuilder Gte(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Gte, value); + return this; + } + + /// + /// Adds an in-list filter to check if column value is in the provided list. + /// + /// The column name to filter on. + /// The list of values to check against. + /// The current builder instance for chaining. + public PostgresFilterBuilder In(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.In, value); + return this; + } + + /// + /// Adds a case-sensitive pattern matching filter (LIKE). + /// + /// The column name to filter on. + /// The pattern to match (supports % and _ wildcards). + /// The current builder instance for chaining. + public PostgresFilterBuilder Like(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Like, value); + return this; + } + + /// + /// Adds a case-insensitive pattern matching filter (ILIKE). + /// + /// The column name to filter on. + /// The pattern to match (supports % and _ wildcards). + /// The current builder instance for chaining. + public PostgresFilterBuilder ILike(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.ILike, value); + return this; + } + + /// + /// Adds a case-sensitive regular expression match filter. + /// + /// The column name to filter on. + /// The regular expression pattern to match. + /// The current builder instance for chaining. + public PostgresFilterBuilder Match(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Match, value); + return this; + } + + /// + /// Adds a case-insensitive regular expression match filter. + /// + /// The column name to filter on. + /// The regular expression pattern to match. + /// The current builder instance for chaining. + public PostgresFilterBuilder IMatch(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.IMatch, value); + return this; + } + + /// + /// Adds a filter to check if column value is distinct from the provided value. + /// + /// The column name to filter on. + /// The value to check distinctness against. + /// The current builder instance for chaining. + public PostgresFilterBuilder IsDistinct(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.IsDistinct, value); + return this; + } + + /// + /// Adds a filter to check if column IS the provided value (null, true, or false). + /// + /// The column name to filter on. + /// The value to check (typically null, true, or false). + /// The current builder instance for chaining. + public PostgresFilterBuilder Is(string column, object? value) + { + this.Add(column, PostgresChangesFilterOperator.Is, value); + return this; + } + + /// + /// Adds a negated filter expression (NOT operator). + /// + /// The column name to filter on. + /// The filter operator to negate. + /// The value to compare against. + /// The current builder instance for chaining. + public PostgresFilterBuilder Not(string column, PostgresChangesFilterOperator op, object? value) + { + this.Add(column, op, value, true); + return this; + } + + /// + /// Builds and returns the final filter string by joining all filter expressions with commas. + /// + /// A comma-separated string of all filter expressions. + public string Build() => string.Join(",", this.filters); +} diff --git a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs new file mode 100644 index 00000000..02d89a25 --- /dev/null +++ b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections; +using System.Globalization; +using System.Linq; + +namespace Supabase.Realtime.PostgresChanges.Filter; + +/// +/// Represents a serialized filter value used in . +/// Handles the conversion and sanitization of filter values based on the specified operator, +/// ensuring they are properly formatted for PostgreSQL change subscriptions. +/// +public class PostgresFilterValue +{ + /// + /// Gets the serialized filter value as a string, ready to be used in a PostgreSQL changes filter. + /// + internal string? Value { get; } + + /// + /// Initializes a new instance of the class. + /// Serializes the provided value according to the specified filter operator. + /// + /// The filter operator that determines how the value should be serialized. + /// The value to serialize, which may be null, a scalar, or a collection depending on the operator. + /// Thrown when an unsupported operator is specified. + /// + /// Thrown when the value type is incompatible with the specified operator + /// (e.g., non-enumerable for 'in' operator, or invalid value for 'is' operator). + /// + public PostgresFilterValue(PostgresChangesFilterOperator operation, object? value) + { + var sanitized = operation switch + { + PostgresChangesFilterOperator.Eq + or PostgresChangesFilterOperator.Neq + or PostgresChangesFilterOperator.Lt + or PostgresChangesFilterOperator.Lte + or PostgresChangesFilterOperator.Gt + or PostgresChangesFilterOperator.Gte + or PostgresChangesFilterOperator.Match + or PostgresChangesFilterOperator.IMatch + or PostgresChangesFilterOperator.Like + or PostgresChangesFilterOperator.ILike + or PostgresChangesFilterOperator.IsDistinct => SerializeScalar(value), + + PostgresChangesFilterOperator.In => SerializeIn(value), + + PostgresChangesFilterOperator.Is => SerializeIsValue(value), + + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + this.Value = sanitized; + } + + /// + /// Determines whether a string value needs to be quoted. + /// Quoting is required for null/empty strings or strings containing special characters + /// such as whitespace, punctuation, or delimiter characters. + /// + /// The string value to check. + /// true if the value needs quoting; otherwise, false. + private static bool NeedQuoting(string? value) + { + if (string.IsNullOrEmpty(value)) + return true; + + return value.Any(character => + char.IsWhiteSpace(character) + || character is ',' or '.' or ':' or '(' or ')' or '"' or '\\' + ); + } + + /// + /// Quotes and escapes a string value for safe transmission. + /// Backslashes and double quotes are escaped, and the result is wrapped in double quotes. + /// + /// The string value to quote. + /// The quoted and escaped string. + private static string Quote(string value) + { + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + return $"\"{escaped}\""; + } + + /// + /// Serializes a string value, quoting it if necessary. + /// + /// The string value to serialize. + /// The serialized string, quoted if it contains special characters. + private static string SerializeString(string value) => + NeedQuoting(value) ? Quote(value) : value; + + /// + /// Serializes a scalar value (null, boolean, number, or string) to its string representation. + /// Handles type-specific formatting: null becomes "null", booleans are lowercased, + /// decimals use invariant culture, and other types are converted to strings and quoted if needed. + /// + /// The scalar value to serialize. + /// The serialized string representation of the value. + private static string SerializeScalar(object? value) => + value switch + { + null => "null", + bool parsed => parsed.ToString().ToLowerInvariant(), + decimal => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null", + string parsed => SerializeString(parsed), + _ => SerializeString(value.ToString() ?? "null"), + }; + + /// + /// Serializes a value for the 'IS' operator, which only accepts null, true, or false. + /// String inputs are checked case-insensitively for these special values. + /// + /// The value to serialize for the IS operator. + /// The serialized value: "null", "true", or "false". + /// Thrown when the value is not null, true, or false. + private static string SerializeIsValue(object? value) => + value switch + { + null => "null", + bool parsed => parsed.ToString().ToLowerInvariant(), + string parsed when parsed.Equals("null", StringComparison.OrdinalIgnoreCase) => "null", + string parsed when parsed.Equals("true", StringComparison.OrdinalIgnoreCase) => "true", + string parsed when parsed.Equals("false", StringComparison.OrdinalIgnoreCase) => + "false", + _ => throw new ArgumentException( + "The 'is' operator only supports null, true, false, or unknown values.", + nameof(value) + ), + }; + + /// + /// Serializes a collection of values for the 'IN' operator. + /// The collection is converted to a comma-separated list enclosed in parentheses, + /// with each value serialized as a scalar. + /// + /// The enumerable collection of values to serialize. + /// A parenthesized, comma-separated list of serialized values. + /// + /// Thrown when the value is not an IEnumerable (excluding string) or when the collection is empty. + /// + private static string SerializeIn(object? value) + { + if (value is string || value is not IEnumerable values) + throw new ArgumentException( + "The 'in' operator only supports IEnumerable values.", + nameof(value) + ); + + var items = values.Cast().ToList(); + if (items.Count == 0) + throw new ArgumentException( + "The 'in' operator requires at least one value.", + nameof(value) + ); + + var serialized = items.Select(SerializeScalar); + + return $"({string.Join(",", serialized)})"; + } +} From 6f25665357bf74ee81f998ad34318845f578ddd1 Mon Sep 17 00:00:00 2001 From: Diego Santo Date: Fri, 21 Aug 2026 21:30:13 -0300 Subject: [PATCH 2/2] feat(realtime): improve scalar validation --- .../Filter/PostgresFilterBuilderTests.cs | 17 +++++++++++++++++ .../Filter/PostgresChangesFilterOperator.cs | 2 +- .../Filter/PostgresFilterValue.cs | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs b/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs index 153a64e4..7ba2bb6c 100644 --- a/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs +++ b/packages/Realtime/Realtime.Tests/PostgresChanges/Filter/PostgresFilterBuilderTests.cs @@ -15,6 +15,8 @@ public class PostgresFilterBuilderTests [DataRow(2, "2")] [DataRow(2D, "2")] [DataRow(2F, "2")] + [DataRow(1.5D, "1.5")] + [DataRow(1.5F, "1.5")] [DataRow(true, "true")] [DataRow("Hello, World", "\"Hello, World\"")] public void Filter_ShouldCreateEqual_GivenInput(object value, string expect) @@ -48,6 +50,21 @@ public void Filter_ShouldCreateEqualChained_GivenInput() Assert.AreEqual(expected, result); } + [TestMethod] + [DataRow("open", "open")] + [DataRow("closed", "closed")] + public void Filter_ShouldCreateIsDistinct_GivenInput(object value, string expect) + { + const string column = "status"; + const PostgresChangesFilterOperator filterOperator = PostgresChangesFilterOperator.IsDistinct; + var expected = $"{column}={filterOperator.ToMappedString()}.{expect}"; + + var result = PostgresFilterBuilder.Builder().IsDistinct(column, value).Build(); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } + [TestMethod] [DataRow("test", "test")] [DataRow("\"test\"", "\"\\\"test\\\"\"")] diff --git a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs index b5addd05..5be85a5f 100644 --- a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs +++ b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresChangesFilterOperator.cs @@ -60,7 +60,7 @@ public enum PostgresChangesFilterOperator IMatch, /// Value is distinct from. - [MapTo("is_distinct")] + [MapTo("isdistinct")] IsDistinct, } diff --git a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs index 02d89a25..1a0aac6e 100644 --- a/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs +++ b/packages/Realtime/Realtime/PostgresChanges/Filter/PostgresFilterValue.cs @@ -104,7 +104,7 @@ private static string SerializeScalar(object? value) => { null => "null", bool parsed => parsed.ToString().ToLowerInvariant(), - decimal => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null", + float or double or decimal => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null", string parsed => SerializeString(parsed), _ => SerializeString(value.ToString() ?? "null"), };