Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,6 +22,8 @@ private class ArrayModel
{
[JsonPropertyName("intArray")] public List<int> IntArray { get; set; } = new();
[JsonPropertyName("stringArray")] public List<string> StringArray { get; set; } = new();
[JsonPropertyName("nestedIntArray")] public List<List<int>> NestedIntArray { get; set; } = new();
[JsonPropertyName("nestedStringArray")] public List<List<string>> NestedStringArray { get; set; } = new();
}

private static ArrayModel Coerce(string json) =>
Expand Down Expand Up @@ -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<List<int>>?) null, StringArray = new[] { "ok" } });

[TestMethod]
public void NestedIntArrayRead_ShouldReturnNullAndKeepReading_GivenAJsonObject() =>
Coerce("""{"nestedIntArray":{},"stringArray":"{ok}"}""")
.Should().BeEquivalentTo(new { NestedIntArray = (List<List<int>>?) 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]]");
}
}
19 changes: 5 additions & 14 deletions packages/Realtime/Realtime/Converters/IntArrayConverter.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,21 +53,11 @@ public override void Write(Utf8JsonWriter writer, List<int> value, JsonSerialize
internal static List<int> Parse(string value)
{
var result = new List<int>();

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;
Expand Down
148 changes: 148 additions & 0 deletions packages/Realtime/Realtime/Converters/PostgresArrayLiteral.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;

namespace Supabase.Realtime.Converters;

/// <summary>
/// Parses a Postgres array literal such as <c>{a,"b,c",NULL}</c>. Values come back as strings, <c>NULL</c> as null
/// and inner arrays as nested lists. Malformed input throws <see cref="JsonException" />.
/// </summary>
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<object?> 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<object?> ReadElements(string text, char close, ref int position, int depth)
{
var elements = new List<object?>();
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<object?> 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}'.");
}
100 changes: 100 additions & 0 deletions packages/Realtime/Realtime/Converters/PostgresNestedArrayConverter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reads a nested Postgres array such as <c>{{1,2},{3,4}}</c> from a string into a <see cref="List{T}" /> of
/// lists of int or string. A regular JSON array is also accepted; writes emit a regular JSON array.
/// </summary>
internal class PostgresNestedArrayConverter : JsonConverterFactory
{
/// <inheritdoc />
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);
}

/// <inheritdoc />
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<object?> 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<object?>)
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<TElement> : JsonConverter<List<TElement>>
{
public override List<TElement>? 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<TElement>) Materialize(literal, typeof(List<TElement>))!;
case JsonTokenType.StartArray:
var list = new List<TElement>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
list.Add(JsonSerializer.Deserialize<TElement>(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<TElement> value, JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var item in value)
JsonSerializer.Serialize(writer, item, options);
writer.WriteEndArray();
}
}
}
Loading