Skip to content

Commit f47be0a

Browse files
authored
Add support for writing JObject and JsonObject without NpgsqlDbType (#4857)
Fixes #4537
1 parent 0acc808 commit f47be0a

5 files changed

Lines changed: 186 additions & 15 deletions

File tree

src/Npgsql.Json.NET/Internal/JsonNetTypeHandlerResolverFactory.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using Newtonsoft.Json;
4+
using Newtonsoft.Json.Linq;
45
using Npgsql.Internal;
56
using Npgsql.Internal.TypeHandling;
67
using Npgsql.TypeMapping;
@@ -23,7 +24,11 @@ public JsonNetTypeHandlerResolverFactory(
2324
_jsonClrTypes = jsonClrTypes ?? Array.Empty<Type>();
2425
_settings = settings ?? new JsonSerializerSettings();
2526

26-
_byType = new();
27+
_byType = new()
28+
{
29+
{ typeof(JObject), "jsonb" },
30+
{ typeof(JArray), "jsonb" }
31+
};
2732

2833
if (jsonbClrTypes is not null)
2934
foreach (var type in jsonbClrTypes)

src/Npgsql/Internal/TypeHandlers/JsonHandler.cs

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
using Npgsql.TypeMapping;
1212
using NpgsqlTypes;
1313

14+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
15+
using System.Text.Json.Nodes;
16+
#endif
17+
1418
namespace Npgsql.Internal.TypeHandlers;
1519

1620
/// <summary>
@@ -66,11 +70,25 @@ protected internal override int ValidateAndGetLengthCustom<TAny>([DisallowNull]
6670
if (lengthCache.IsPopulated)
6771
return lengthCache.Get();
6872

69-
var data = SerializeJsonDocument((JsonDocument)(object)value!);
73+
var data = SerializeJsonDocument((JsonDocument)(object)value);
7074
if (parameter != null)
7175
parameter.ConvertedValue = data;
7276
return lengthCache.Set(data.Length + _headerLen);
7377
}
78+
79+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
80+
if (typeof(TAny) == typeof(JsonObject) || typeof(TAny) == typeof(JsonArray))
81+
{
82+
lengthCache ??= new NpgsqlLengthCache(1);
83+
if (lengthCache.IsPopulated)
84+
return lengthCache.Get();
85+
86+
var data = SerializeJsonObject((JsonNode)(object)value);
87+
if (parameter != null)
88+
parameter.ConvertedValue = data;
89+
return lengthCache.Set(data.Length + _headerLen);
90+
}
91+
#endif
7492

7593
// User POCO, need to serialize. At least internally ArrayPool buffers are used...
7694
var s = JsonSerializer.Serialize(value, _serializerOptions);
@@ -94,30 +112,39 @@ protected override async Task WriteWithLengthCustom<TAny>([DisallowNull] TAny va
94112
buf.WriteByte(JsonbProtocolVersion);
95113

96114
if (typeof(TAny) == typeof(string))
97-
await _textHandler.Write((string)(object)value!, buf, lengthCache, parameter, async, cancellationToken);
115+
await _textHandler.Write((string)(object)value, buf, lengthCache, parameter, async, cancellationToken);
98116
else if (typeof(TAny) == typeof(char[]))
99-
await _textHandler.Write((char[])(object)value!, buf, lengthCache, parameter, async, cancellationToken);
117+
await _textHandler.Write((char[])(object)value, buf, lengthCache, parameter, async, cancellationToken);
100118
else if (typeof(TAny) == typeof(ArraySegment<char>))
101-
await _textHandler.Write((ArraySegment<char>)(object)value!, buf, lengthCache, parameter, async, cancellationToken);
119+
await _textHandler.Write((ArraySegment<char>)(object)value, buf, lengthCache, parameter, async, cancellationToken);
102120
else if (typeof(TAny) == typeof(char))
103-
await _textHandler.Write((char)(object)value!, buf, lengthCache, parameter, async, cancellationToken);
121+
await _textHandler.Write((char)(object)value, buf, lengthCache, parameter, async, cancellationToken);
104122
else if (typeof(TAny) == typeof(byte[]))
105-
await _textHandler.Write((byte[])(object)value!, buf, lengthCache, parameter, async, cancellationToken);
123+
await _textHandler.Write((byte[])(object)value, buf, lengthCache, parameter, async, cancellationToken);
106124
else if (typeof(TAny) == typeof(ReadOnlyMemory<byte>))
107-
await _textHandler.Write((ReadOnlyMemory<byte>)(object)value!, buf, lengthCache, parameter, async, cancellationToken);
125+
await _textHandler.Write((ReadOnlyMemory<byte>)(object)value, buf, lengthCache, parameter, async, cancellationToken);
108126
else if (typeof(TAny) == typeof(JsonDocument))
109127
{
110128
var data = parameter?.ConvertedValue != null
111129
? (byte[])parameter.ConvertedValue
112-
: SerializeJsonDocument((JsonDocument)(object)value!);
130+
: SerializeJsonDocument((JsonDocument)(object)value);
131+
await buf.WriteBytesRaw(data, async, cancellationToken);
132+
}
133+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
134+
else if (typeof(TAny) == typeof(JsonObject) || typeof(TAny) == typeof(JsonArray))
135+
{
136+
var data = parameter?.ConvertedValue != null
137+
? (byte[])parameter.ConvertedValue
138+
: SerializeJsonObject((JsonNode)(object)value);
113139
await buf.WriteBytesRaw(data, async, cancellationToken);
114140
}
141+
#endif
115142
else
116143
{
117144
// User POCO, read serialized representation from the validation phase
118145
var s = parameter?.ConvertedValue != null
119146
? (string)parameter.ConvertedValue
120-
: JsonSerializer.Serialize(value!, value!.GetType(), _serializerOptions);
147+
: JsonSerializer.Serialize(value, value.GetType(), _serializerOptions);
121148

122149
await _textHandler.Write(s, buf, lengthCache, parameter, async, cancellationToken);
123150
}
@@ -151,6 +178,10 @@ public override int ValidateObjectAndGetLength(object value, ref NpgsqlLengthCac
151178
byte[] s => ValidateAndGetLength(s, ref lengthCache, parameter),
152179
ReadOnlyMemory<byte> s => ValidateAndGetLength(s, ref lengthCache, parameter),
153180
JsonDocument jsonDocument => ValidateAndGetLength(jsonDocument, ref lengthCache, parameter),
181+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
182+
JsonObject jsonObject => ValidateAndGetLength(jsonObject, ref lengthCache, parameter),
183+
JsonArray jsonArray => ValidateAndGetLength(jsonArray, ref lengthCache, parameter),
184+
#endif
154185
_ => ValidateAndGetLength(value, ref lengthCache, parameter)
155186
};
156187

@@ -172,6 +203,10 @@ public override async Task WriteObjectWithLength(object? value, NpgsqlWriteBuffe
172203
byte[] s => WriteWithLengthCustom(s, buf, lengthCache, parameter, async, cancellationToken),
173204
ReadOnlyMemory<byte> s => WriteWithLengthCustom(s, buf, lengthCache, parameter, async, cancellationToken),
174205
JsonDocument jsonDocument => WriteWithLengthCustom(jsonDocument, buf, lengthCache, parameter, async, cancellationToken),
206+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
207+
JsonObject jsonObject => WriteWithLengthCustom(jsonObject, buf, lengthCache, parameter, async, cancellationToken),
208+
JsonArray jsonArray => WriteWithLengthCustom(jsonArray, buf, lengthCache, parameter, async, cancellationToken),
209+
#endif
175210
_ => WriteWithLengthCustom(value, buf, lengthCache, parameter, async, cancellationToken),
176211
});
177212
}
@@ -243,4 +278,17 @@ byte[] SerializeJsonDocument(JsonDocument document)
243278
writer.Flush();
244279
return stream.ToArray();
245280
}
281+
282+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
283+
byte[] SerializeJsonObject(JsonNode jsonObject)
284+
{
285+
// TODO: Writing is currently really inefficient - please don't criticize :)
286+
// We need to implement one-pass writing to serialize directly to the buffer (or just switch to pipelines).
287+
using var stream = new MemoryStream();
288+
using var writer = new Utf8JsonWriter(stream);
289+
jsonObject.WriteTo(writer);
290+
writer.Flush();
291+
return stream.ToArray();
292+
}
293+
#endif
246294
}

src/Npgsql/TypeMapping/BuiltInTypeHandlerResolver.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
using NpgsqlTypes;
2424
using static Npgsql.Util.Statics;
2525

26+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
27+
using System.Text.Json.Nodes;
28+
#endif
29+
2630
namespace Npgsql.TypeMapping;
2731

2832
sealed class BuiltInTypeHandlerResolver : TypeHandlerResolver
@@ -54,7 +58,11 @@ sealed class BuiltInTypeHandlerResolver : TypeHandlerResolver
5458
{ "name", new(NpgsqlDbType.Name, "name") },
5559
{ "refcursor", new(NpgsqlDbType.Refcursor, "refcursor") },
5660
{ "citext", new(NpgsqlDbType.Citext, "citext") },
57-
{ "jsonb", new(NpgsqlDbType.Jsonb, "jsonb", typeof(JsonDocument)) },
61+
{ "jsonb", new(NpgsqlDbType.Jsonb, "jsonb", typeof(JsonDocument)
62+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
63+
, typeof(JsonObject), typeof(JsonArray)
64+
#endif
65+
) },
5866
{ "json", new(NpgsqlDbType.Json, "json") },
5967
{ "jsonpath", new(NpgsqlDbType.JsonPath, "jsonpath") },
6068

@@ -397,6 +405,10 @@ static BuiltInTypeHandlerResolver()
397405
{ typeof(char), "text" },
398406
{ typeof(ArraySegment<char>), "text" },
399407
{ typeof(JsonDocument), "jsonb" },
408+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
409+
{ typeof(JsonObject), "jsonb" },
410+
{ typeof(JsonArray), "jsonb" },
411+
#endif
400412

401413
// Date/time types
402414
// The DateTime entry is for LegacyTimestampBehavior mode only. In regular mode we resolve through
@@ -599,6 +611,12 @@ static DateTimeKind GetMultirangeKind(IList<NpgsqlRange<DateTime>> multirange)
599611
return _textHandler;
600612
if (typeof(T) == typeof(JsonDocument))
601613
return JsonbHandler();
614+
#if NET6_0_OR_GREATER || NETSTANDARD2_0 || NETSTANDARD2_1
615+
if (typeof(T) == typeof(JsonObject))
616+
return JsonbHandler();
617+
if (typeof(T) == typeof(JsonArray))
618+
return JsonbHandler();
619+
#endif
602620

603621
// Date/time types
604622
// No resolution for DateTime, since that's value-dependent (Kind)

test/Npgsql.PluginTests/JsonNetTests.cs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ public Task Roundtrip_JObject()
7272
IsJsonb ? @"{""Bar"": 8}" : @"{""Bar"":8}",
7373
_pgTypeName,
7474
_npgsqlDbType,
75-
isDefault: false,
75+
// By default we map JObject to jsonb
76+
isDefaultForWriting: IsJsonb,
77+
isDefaultForReading: false,
7678
isNpgsqlDbTypeInferredFromClrType: false);
7779

7880
[Test]
@@ -83,7 +85,9 @@ public Task Roundtrip_JArray()
8385
IsJsonb ? "[1, 2, 3]" : "[1,2,3]",
8486
_pgTypeName,
8587
_npgsqlDbType,
86-
isDefault: false,
88+
// By default we map JArray to jsonb
89+
isDefaultForWriting: IsJsonb,
90+
isDefaultForReading: false,
8791
isNpgsqlDbTypeInferredFromClrType: false);
8892

8993
[Test]
@@ -168,6 +172,7 @@ await AssertType(
168172
isDefault: false,
169173
isNpgsqlDbTypeInferredFromClrType: false);
170174
}
175+
171176
[Test]
172177
public async Task Bug3464()
173178
{
@@ -190,8 +195,37 @@ public class Bug3464Class
190195
public string? SomeString { get; set; }
191196
}
192197

193-
readonly NpgsqlDbType _npgsqlDbType;
194-
readonly string _pgTypeName;
198+
[Test]
199+
[IssueLink("https://github.com/npgsql/npgsql/issues/4537")]
200+
public async Task Write_jobject_array_without_npgsqldbtype()
201+
{
202+
// By default we map JObject to jsonb
203+
if (!IsJsonb)
204+
return;
205+
206+
await using var conn = await JsonDataSource.OpenConnectionAsync();
207+
var tableName = await TestUtil.CreateTempTable(conn, "key SERIAL PRIMARY KEY, ingredients json[]");
208+
209+
await using var cmd = new NpgsqlCommand { Connection = conn };
210+
211+
var jsonObject1 = new JObject
212+
{
213+
{ "name", "value1" },
214+
{ "amount", 1 },
215+
{ "unit", "ml" }
216+
};
217+
218+
var jsonObject2 = new JObject
219+
{
220+
{ "name", "value2" },
221+
{ "amount", 2 },
222+
{ "unit", "g" }
223+
};
224+
225+
cmd.CommandText = $"INSERT INTO {tableName} (ingredients) VALUES (@p)";
226+
cmd.Parameters.Add(new("p", new[] { jsonObject1, jsonObject2 }));
227+
await cmd.ExecuteNonQueryAsync();
228+
}
195229

196230
class Foo
197231
{
@@ -200,6 +234,9 @@ class Foo
200234
public override int GetHashCode() => Bar.GetHashCode();
201235
}
202236

237+
readonly NpgsqlDbType _npgsqlDbType;
238+
readonly string _pgTypeName;
239+
203240
[OneTimeSetUp]
204241
public void SetUp()
205242
{

test/Npgsql.Tests/Types/JsonTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Text;
33
using System.Text.Json;
4+
using System.Text.Json.Nodes;
45
using System.Threading.Tasks;
56
using NpgsqlTypes;
67
using NUnit.Framework;
@@ -69,6 +70,34 @@ public async Task As_JsonDocument()
6970
isDefault: false,
7071
comparer: (x, y) => x.RootElement.GetProperty("K").GetString() == y.RootElement.GetProperty("K").GetString());
7172

73+
#if NET6_0_OR_GREATER
74+
[Test]
75+
public Task Roundtrip_JsonObject()
76+
=> AssertType(
77+
new JsonObject { ["Bar"] = 8 },
78+
IsJsonb ? @"{""Bar"": 8}" : @"{""Bar"":8}",
79+
PostgresType,
80+
NpgsqlDbType,
81+
// By default we map JsonObject to jsonb
82+
isDefaultForWriting: IsJsonb,
83+
isDefaultForReading: false,
84+
isNpgsqlDbTypeInferredFromClrType: false,
85+
comparer: (x, y) => x.ToString() == y.ToString());
86+
87+
[Test]
88+
public Task Roundtrip_JsonArray()
89+
=> AssertType(
90+
new JsonArray { 1, 2, 3 },
91+
IsJsonb ? "[1, 2, 3]" : "[1,2,3]",
92+
PostgresType,
93+
NpgsqlDbType,
94+
// By default we map JsonArray to jsonb
95+
isDefaultForWriting: IsJsonb,
96+
isDefaultForReading: false,
97+
isNpgsqlDbTypeInferredFromClrType: false,
98+
comparer: (x, y) => x.ToString() == y.ToString());
99+
#endif
100+
72101
[Test]
73102
public async Task As_poco()
74103
=> await AssertType(
@@ -141,6 +170,40 @@ public async Task Can_read_two_json_documents()
141170
Assert.That(car.RootElement.GetProperty("key").GetString(), Is.EqualTo("foo"));
142171
}
143172

173+
#if NET6_0_OR_GREATER
174+
[Test]
175+
[IssueLink("https://github.com/npgsql/npgsql/issues/4537")]
176+
public async Task Write_jsonobject_array_without_npgsqldbtype()
177+
{
178+
// By default we map JsonObject to jsonb
179+
if (!IsJsonb)
180+
return;
181+
182+
await using var conn = await OpenConnectionAsync();
183+
var tableName = await TestUtil.CreateTempTable(conn, "key SERIAL PRIMARY KEY, ingredients json[]");
184+
185+
await using var cmd = new NpgsqlCommand { Connection = conn };
186+
187+
var jsonObject1 = new JsonObject
188+
{
189+
{ "name", "value1" },
190+
{ "amount", 1 },
191+
{ "unit", "ml" }
192+
};
193+
194+
var jsonObject2 = new JsonObject
195+
{
196+
{ "name", "value2" },
197+
{ "amount", 2 },
198+
{ "unit", "g" }
199+
};
200+
201+
cmd.CommandText = $"INSERT INTO {tableName} (ingredients) VALUES (@p)";
202+
cmd.Parameters.Add(new("p", new[] { jsonObject1, jsonObject2 }));
203+
await cmd.ExecuteNonQueryAsync();
204+
}
205+
#endif
206+
144207
public JsonTests(MultiplexingMode multiplexingMode, NpgsqlDbType npgsqlDbType)
145208
: base(multiplexingMode)
146209
{

0 commit comments

Comments
 (0)