diff --git a/packages/Realtime/Realtime.Tests/Serialization/ArrayConverterTests.cs b/packages/Realtime/Realtime.Tests/Serialization/ArrayConverterTests.cs index e2eda690..b9612dfc 100644 --- a/packages/Realtime/Realtime.Tests/Serialization/ArrayConverterTests.cs +++ b/packages/Realtime/Realtime.Tests/Serialization/ArrayConverterTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -21,6 +22,8 @@ private class ArrayModel { [JsonPropertyName("intArray")] public List IntArray { get; set; } = new(); [JsonPropertyName("stringArray")] public List StringArray { get; set; } = new(); + [JsonPropertyName("nestedIntArray")] public List> NestedIntArray { get; set; } = new(); + [JsonPropertyName("nestedStringArray")] public List> NestedStringArray { get; set; } = new(); } private static ArrayModel Coerce(string json) => @@ -63,4 +66,89 @@ public void StringArrayParse_ShouldReadBothForms() StringArrayConverter.Parse("{a,b,c}").Should().Equal("a", "b", "c"); StringArrayConverter.Parse("[a,b,c]").Should().Equal("a", "b", "c"); } + + [TestMethod] + public void StringArrayParse_ShouldHonorQuotesAndEscapes() => + StringArrayConverter.Parse("""{"a,b","c\"d","e\\f",""}""") + .Should().Equal("a,b", "c\"d", "e\\f", ""); + + [TestMethod] + public void StringArrayParse_ShouldKeepUnicodeSpaces() => + StringArrayConverter.Parse("{a\u00A0,\u00A0,NULL\u00A0}").Should().Equal("a\u00A0", "\u00A0", "NULL\u00A0"); + + [TestMethod] + public void StringArrayParse_ShouldReadNullElement_GivenUnquotedNull() => + StringArrayConverter.Parse("""{NULL,"NULL"}""") + .Should().Equal(new[] { null!, "NULL" }, "a quoted NULL is text, not the null element"); + + [TestMethod] + public void StringArrayParse_ShouldIgnoreWhitespace_GivenUnquotedElements() => + StringArrayConverter.Parse(" {a , b} ").Should().Equal("a", "b"); + + [TestMethod] + public void StringArrayParse_ShouldReturnEmpty_GivenWhitespaceOnlyBraces() => + StringArrayConverter.Parse("{ }").Should().BeEmpty(); + + [TestMethod] + [DataRow("{a,}")] + [DataRow("{a}b")] + [DataRow("""{"a}""")] + [DataRow("""{a"b}""")] + [DataRow("{a{b}")] + [DataRow("{a,,b}")] + [DataRow("abc")] + public void StringArrayRead_ShouldReturnNull_GivenMalformedLiteral(string literal) => + Coerce(JsonSerializer.Serialize(new { stringArray = literal })).StringArray.Should().BeNull(); + + [TestMethod] + [DataRow("{1,}")] + [DataRow("{1,NULL}")] + [DataRow("{{1,2},{3,4}}")] + [DataRow("[0:1]={1,2}")] + public void IntArrayRead_ShouldReturnNull_GivenUnreadableLiteral(string literal) => + Coerce(JsonSerializer.Serialize(new { intArray = literal })).IntArray.Should().BeNull(); + + [TestMethod] + public void NestedIntArrayRead_ShouldKeepShape_GivenNestedLiteral() => + Coerce("""{"nestedIntArray":"{{1,2},{3,4}}"}""").NestedIntArray + .Should().BeEquivalentTo(new[] { new[] { 1, 2 }, new[] { 3, 4 } }, options => options.WithStrictOrdering()); + + [TestMethod] + public void NestedStringArrayRead_ShouldKeepShape_GivenQuotedAndNullElements() => + Coerce("""{"nestedStringArray":"{{\"a,b\",NULL},{c}}"}""").NestedStringArray + .Should().BeEquivalentTo(new[] { new[] { "a,b", null }, new[] { "c" } }, options => options.WithStrictOrdering()); + + [TestMethod] + public void NestedIntArrayRead_ShouldKeepShape_GivenJsonArrays() => + Coerce("""{"nestedIntArray":[[1,2],[3,4]]}""").NestedIntArray + .Should().BeEquivalentTo(new[] { new[] { 1, 2 }, new[] { 3, 4 } }, options => options.WithStrictOrdering()); + + [TestMethod] + [DataRow("{1,2,3}")] + [DataRow("{{1,2},3}")] + [DataRow("{{1,2}")] + public void NestedIntArrayRead_ShouldReturnNull_GivenUnreadableLiteral(string literal) => + Coerce(JsonSerializer.Serialize(new { nestedIntArray = literal })).NestedIntArray.Should().BeNull(); + + [TestMethod] + public void NestedIntArrayRead_ShouldReturnNullAndKeepReading_GivenAJsonArrayWithABadElement() => + Coerce("""{"nestedIntArray":[[1,2],["x",4]],"stringArray":"{ok}"}""") + .Should().BeEquivalentTo(new { NestedIntArray = (List>?) null, StringArray = new[] { "ok" } }); + + [TestMethod] + public void NestedIntArrayRead_ShouldReturnNullAndKeepReading_GivenAJsonObject() => + Coerce("""{"nestedIntArray":{},"stringArray":"{ok}"}""") + .Should().BeEquivalentTo(new { NestedIntArray = (List>?) null, StringArray = new[] { "ok" } }); + + [TestMethod] + public void IntArrayRead_ShouldReturnNull_GivenDeeplyNestedBraces() => + Coerce(JsonSerializer.Serialize(new { intArray = new string('{', 100_000) })).IntArray.Should().BeNull(); + + [TestMethod] + public void NestedIntArrayWrite_ShouldEmitJsonArrays() + { + var model = new ArrayModel { NestedIntArray = new() { new() { 1, 2 }, new() { 3, 4 } } }; + var json = JsonNode.Parse(JsonSerializer.Serialize(model, Wire.Settings()))!; + json["nestedIntArray"]!.ToJsonString().Should().Be("[[1,2],[3,4]]"); + } } diff --git a/packages/Realtime/Realtime/Converters/IntArrayConverter.cs b/packages/Realtime/Realtime/Converters/IntArrayConverter.cs index e1676c03..dd7eba3f 100644 --- a/packages/Realtime/Realtime/Converters/IntArrayConverter.cs +++ b/packages/Realtime/Realtime/Converters/IntArrayConverter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -52,21 +53,11 @@ public override void Write(Utf8JsonWriter writer, List value, JsonSerialize internal static List Parse(string value) { var result = new List(); - - if (string.IsNullOrEmpty(value)) - return result; - - var firstChar = value[0]; - var lastChar = value[value.Length - 1]; - - var isBraced = (firstChar == '{' && lastChar == '}') || (firstChar == '[' && lastChar == ']'); - if (!isBraced) - return result; - - foreach (var item in value.Trim('{', '}', '[', ']').Split(',')) + foreach (var item in PostgresArrayLiteral.Parse(value)) { - if (string.IsNullOrEmpty(item)) continue; - result.Add(int.Parse(item)); + if (item is not string element) + throw new JsonException($"'{value}' is not a flat array of ints."); + result.Add(int.Parse(element, CultureInfo.InvariantCulture)); } return result; diff --git a/packages/Realtime/Realtime/Converters/PostgresArrayLiteral.cs b/packages/Realtime/Realtime/Converters/PostgresArrayLiteral.cs new file mode 100644 index 00000000..10e36e63 --- /dev/null +++ b/packages/Realtime/Realtime/Converters/PostgresArrayLiteral.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace Supabase.Realtime.Converters; + +/// +/// Parses a Postgres array literal such as {a,"b,c",NULL}. Values come back as strings, NULL as null +/// and inner arrays as nested lists. Malformed input throws . +/// +internal static class PostgresArrayLiteral +{ + // Postgres treats only these six characters as whitespace inside a literal. + private static readonly char[] Spaces = { ' ', '\t', '\n', '\r', '\v', '\f' }; + + internal static List Parse(string literal) + { + var text = literal.Trim(Spaces); + if (text.Length < 2 || (text[0] != '{' && text[0] != '[')) + { + throw Malformed(text); + } + + // [0:1]={1,2} has custom bounds, [1,2,3] is the old bracket form. + if (text[0] == '[' && text[text.Length - 1] == '}') + { + throw new JsonException($"Array bounds are not supported: '{text}'."); + } + + var close = text[0] == '[' ? ']' : '}'; + var position = 1; + var elements = ReadElements(text, close, ref position, 1); + if (position != text.Length) + { + throw Malformed(text); + } + + return elements; + } + + private static List ReadElements(string text, char close, ref int position, int depth) + { + var elements = new List(); + SkipWhitespace(text, ref position); + if (position < text.Length && text[position] == close) + { + position++; + return elements; + } + + while (position < text.Length) + { + elements.Add(text[position] switch + { + '{' => ReadNested(text, ref position, depth), + '"' => ReadQuoted(text, ref position), + _ => ReadUnquoted(text, ref position, close), + }); + + SkipWhitespace(text, ref position); + if (position < text.Length && text[position] == close) + { + position++; + return elements; + } + + if (position >= text.Length || text[position] != ',') + { + throw Malformed(text); + } + + position++; + SkipWhitespace(text, ref position); + } + + throw Malformed(text); + } + + private static List ReadNested(string text, ref int position, int depth) + { + // Postgres arrays have at most six dimensions. + if (depth == 6) + { + throw Malformed(text); + } + + position++; + return ReadElements(text, '}', ref position, depth + 1); + } + + private static string ReadQuoted(string text, ref int position) + { + var element = new StringBuilder(); + position++; + while (position < text.Length) + { + var character = text[position++]; + if (character == '"') + { + return element.ToString(); + } + + if (character == '\\' && position < text.Length) + { + character = text[position++]; + } + + element.Append(character); + } + + throw Malformed(text); + } + + private static string? ReadUnquoted(string text, ref int position, char close) + { + var start = position; + while (position < text.Length && text[position] != ',' && text[position] != close) + { + // Postgres quotes any element that contains a quote, brace or backslash, so a bare one is malformed. + if (text[position] == '"' || text[position] == '{' || text[position] == '\\') + { + throw Malformed(text); + } + + position++; + } + + var value = text.Substring(start, position - start).TrimEnd(Spaces); + if (value.Length == 0) + { + throw Malformed(text); + } + + return value.Equals("NULL", StringComparison.OrdinalIgnoreCase) ? null : value; + } + + private static void SkipWhitespace(string text, ref int position) + { + while (position < text.Length && Array.IndexOf(Spaces, text[position]) >= 0) + { + position++; + } + } + + private static JsonException Malformed(string text) => + new($"Malformed Postgres array literal '{text}'."); +} diff --git a/packages/Realtime/Realtime/Converters/PostgresNestedArrayConverter.cs b/packages/Realtime/Realtime/Converters/PostgresNestedArrayConverter.cs new file mode 100644 index 00000000..f8214100 --- /dev/null +++ b/packages/Realtime/Realtime/Converters/PostgresNestedArrayConverter.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Supabase.Realtime.Converters; + +/// +/// Reads a nested Postgres array such as {{1,2},{3,4}} from a string into a of +/// lists of int or string. A regular JSON array is also accepted; writes emit a regular JSON array. +/// +internal class PostgresNestedArrayConverter : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + if (!IsList(typeToConvert) || !IsList(typeToConvert.GetGenericArguments()[0])) + return false; + var leaf = Leaf(typeToConvert); + return leaf == typeof(int) || leaf == typeof(string); + } + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter) Activator.CreateInstance( + typeof(NestedConverter<>).MakeGenericType(typeToConvert.GetGenericArguments()[0]))!; + + private static bool IsList(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>); + + private static Type Leaf(Type type) => + IsList(type) ? Leaf(type.GetGenericArguments()[0]) : type; + + private static object? Materialize(object? element, Type type) + { + if (IsList(type)) + { + if (element is not List children) + throw new JsonException($"Expected a nested array for {type}."); + + var list = (IList) Activator.CreateInstance(type)!; + foreach (var child in children) + list.Add(Materialize(child, type.GetGenericArguments()[0])); + return list; + } + + if (element is List) + throw new JsonException($"Expected a single value for {type}."); + + if (type == typeof(int)) + return int.Parse((string) element!, CultureInfo.InvariantCulture); + + return element; + } + + private class NestedConverter : JsonConverter> + { + public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + var start = reader; + try + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + var literal = PostgresArrayLiteral.Parse(reader.GetString()!); + return (List) Materialize(literal, typeof(List))!; + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + list.Add(JsonSerializer.Deserialize(ref reader, options)!); + return list; + default: + reader.Skip(); + return null; + } + } + catch + { + // A JSON array may be half read; rewind and skip it so the rest of the payload still parses. + reader = start; + reader.Skip(); + return null; + } + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in value) + JsonSerializer.Serialize(writer, item, options); + writer.WriteEndArray(); + } + } +} diff --git a/packages/Realtime/Realtime/Converters/StringArrayConverter.cs b/packages/Realtime/Realtime/Converters/StringArrayConverter.cs index ea268e54..23a1b6ab 100644 --- a/packages/Realtime/Realtime/Converters/StringArrayConverter.cs +++ b/packages/Realtime/Realtime/Converters/StringArrayConverter.cs @@ -11,7 +11,7 @@ namespace Supabase.Realtime.Converters; /// /// A string array converter that specifically parses Postgrest styled arrays `{big,string,array}` and /// `[1,2,3]` from strings into a . A regular JSON array is also accepted; writes emit a -/// regular JSON array. +/// regular JSON array. An unquoted `NULL` element reads as null. /// public class StringArrayConverter : JsonConverter> { @@ -53,21 +53,11 @@ public override void Write(Utf8JsonWriter writer, List value, JsonSerial internal static List Parse(string value) { var result = new List(); - - if (string.IsNullOrEmpty(value)) - return result; - - var firstChar = value[0]; - var lastChar = value[value.Length - 1]; - - var isBraced = (firstChar == '{' && lastChar == '}') || (firstChar == '[' && lastChar == ']'); - if (!isBraced) - return result; - - foreach (var item in value.Trim('{', '}', '[', ']').Split(',')) + foreach (var item in PostgresArrayLiteral.Parse(value)) { - if (string.IsNullOrEmpty(item)) continue; - result.Add(item); + if (item is List) + throw new JsonException($"'{value}' is not a flat array of strings."); + result.Add((string) item!); } return result; diff --git a/packages/Realtime/Realtime/RealtimeSerializerOptions.cs b/packages/Realtime/Realtime/RealtimeSerializerOptions.cs index 3f89d099..55ef5b5b 100644 --- a/packages/Realtime/Realtime/RealtimeSerializerOptions.cs +++ b/packages/Realtime/Realtime/RealtimeSerializerOptions.cs @@ -43,6 +43,7 @@ internal static JsonSerializerOptions Build(ClientOptions? options = null) new DateTimeListConverter(options.DateTimeFormat), new IntArrayConverter(), new StringArrayConverter(), + new PostgresNestedArrayConverter(), new ObjectToInferredTypesConverter(), }, };