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
Bypass CompositeBuilder for parameterless composite types (#6452)
Skip CompositeBuilder when ConstructorParameters == 0 by reading
and setting field values directly on the instance via new
ReadAndSet/SetDbNull methods on CompositeFieldInfo.
  • Loading branch information
yykkibbb committed Feb 19, 2026
commit fde8d8d872fa02ff76e0e5ba4a983d6237f1f996
70 changes: 70 additions & 0 deletions src/Npgsql/Internal/Composites/Metadata/CompositeFieldInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,42 @@ protected ValueTask WriteAsObject(bool async, PgConverter converter, PgWriter wr

public abstract void ReadDbNull(CompositeBuilder builder);
public abstract ValueTask Read(bool async, PgConverter converter, CompositeBuilder builder, PgReader reader, CancellationToken cancellationToken = default);

/// <summary>
/// Sets the default value directly on the instance, bypassing the builder.
/// Used in the fast path when ConstructorParameters == 0.
/// </summary>
public abstract void SetDbNull(object instance);

/// <summary>
/// Reads a value from the reader and sets it directly on the instance, bypassing the builder.
/// Used in the fast path when ConstructorParameters == 0.
/// </summary>
public abstract ValueTask ReadAndSet(bool async, PgConverter converter, object instance, PgReader reader, CancellationToken cancellationToken = default);

protected abstract void SetValue(object instance, object value);

protected ValueTask ReadAndSetAsObject(bool async, PgConverter converter, object instance, PgReader reader, CancellationToken cancellationToken)
{
if (async)
{
var task = converter.ReadAsObjectAsync(reader, cancellationToken);
if (!task.IsCompletedSuccessfully)
return Core(instance, task);

SetValue(instance, task.Result);
}
else
SetValue(instance, converter.ReadAsObject(reader));
return new();

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
async ValueTask Core(object instance, ValueTask<object> task)
{
SetValue(instance, await task.ConfigureAwait(false));
}
}

public abstract bool IsDbNull(PgConverter converter, object instance, ref object? writeState);
public abstract Size? GetSizeOrDbNull(PgConverter converter, DataFormat format, Size writeRequirement, object instance, ref object? writeState);
public abstract ValueTask Write(bool async, PgConverter converter, PgWriter writer, object instance, CancellationToken cancellationToken);
Expand Down Expand Up @@ -188,6 +224,40 @@ public override void ReadDbNull(CompositeBuilder builder)
builder.AddValue((T?)default);
}

public override void SetDbNull(object instance)
{
if (default(T) != null)
throw new InvalidCastException($"Type {typeof(T).FullName} does not have null as a possible value.");

Set(instance, default(T)!);
}

protected override void SetValue(object instance, object value) => Set(instance, (T)value);

public override ValueTask ReadAndSet(bool async, PgConverter converter, object instance, PgReader reader, CancellationToken cancellationToken = default)
{
if (AsObject(converter))
return ReadAndSetAsObject(async, converter, instance, reader, cancellationToken);

if (async)
{
var task = ((PgConverter<T>)converter).ReadAsync(reader, cancellationToken);
if (!task.IsCompletedSuccessfully)
return Core(instance, task);

Set(instance, task.Result);
}
else
Set(instance, ((PgConverter<T>)converter).Read(reader));
return new();

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
async ValueTask Core(object instance, ValueTask<T> task)
{
Set(instance, await task.ConfigureAwait(false));
}
}

protected override PgConverter BindValue(object instance, out Size writeRequirement)
{
var value = _getter(instance);
Expand Down
74 changes: 64 additions & 10 deletions src/Npgsql/Internal/Converters/CompositeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,62 @@ async ValueTask<T> Read(bool async, PgReader reader, CancellationToken cancellat
if (reader.ShouldBuffer(sizeof(int)))
await reader.Buffer(async, sizeof(int), cancellationToken).ConfigureAwait(false);

// TODO we can make a nice thread-static cache for this.
using var builder = new CompositeBuilder<T>(_composite);

var count = reader.ReadInt32();
if (count != _composite.Fields.Count)
throw new InvalidOperationException("Cannot read composite type with mismatched number of fields.");

// Fast path: when there are no constructor parameters, bypass CompositeBuilder entirely
// and set fields directly on the instance to avoid per-read builder allocations.
if (_composite.ConstructorParameters == 0)
return await ReadDirect(async, reader, cancellationToken).ConfigureAwait(false);

return await ReadWithBuilder(async, reader, cancellationToken).ConfigureAwait(false);
}

async ValueTask<T> ReadDirect(bool async, PgReader reader, CancellationToken cancellationToken)
{
var instance = _composite.Constructor([]);
var boxedInstance = (object)instance!;

foreach (var field in _composite.Fields)
{
if (reader.ShouldBuffer(sizeof(uint) + sizeof(int)))
await reader.Buffer(async, sizeof(uint) + sizeof(int), cancellationToken).ConfigureAwait(false);

var oid = reader.ReadUInt32();
var length = reader.ReadInt32();

ValidateOid(oid, field);

if (length is -1)
field.SetDbNull(boxedInstance);
else
{
var converter = field.GetReadInfo(out var readRequirement);
var scope = await reader.BeginNestedRead(async, length, readRequirement, cancellationToken).ConfigureAwait(false);
try
{
await field.ReadAndSet(async, converter, boxedInstance, reader, cancellationToken).ConfigureAwait(false);
}
finally
{
if (async)
await scope.DisposeAsync().ConfigureAwait(false);
else
scope.Dispose();
}
}
}

// For value types, fields were set on the boxed copy, so we must unbox to get the modified value.
return (T)boxedInstance;
}

async ValueTask<T> ReadWithBuilder(bool async, PgReader reader, CancellationToken cancellationToken)
{
// TODO we can make a nice thread-static cache for this.
using var builder = new CompositeBuilder<T>(_composite);

foreach (var field in _composite.Fields)
{
if (reader.ShouldBuffer(sizeof(uint) + sizeof(int)))
Expand All @@ -79,13 +128,7 @@ async ValueTask<T> Read(bool async, PgReader reader, CancellationToken cancellat
var oid = reader.ReadUInt32();
var length = reader.ReadInt32();

// We're only requiring the PgTypeIds to be oids if this converter is actually used during execution.
// As a result we can still introspect in the global mapper and create all the info with portable ids.
if(oid != field.PgTypeId.Oid)
// We could remove this requirement by storing a dictionary of CompositeInfos keyed by backend.
throw new InvalidCastException(
$"Cannot read oid {oid} into composite field {field.Name} with oid {field.PgTypeId}. " +
$"This could be caused by a DDL change after this DataSource loaded its types, or a difference between column order of table composites between backends, make sure these line up identically.");
ValidateOid(oid, field);

if (length is -1)
field.ReadDbNull(builder);
Expand All @@ -110,6 +153,17 @@ async ValueTask<T> Read(bool async, PgReader reader, CancellationToken cancellat
return builder.Complete();
}

static void ValidateOid(uint oid, CompositeFieldInfo field)
{
// We're only requiring the PgTypeIds to be oids if this converter is actually used during execution.
// As a result we can still introspect in the global mapper and create all the info with portable ids.
if (oid != field.PgTypeId.Oid)
// We could remove this requirement by storing a dictionary of CompositeInfos keyed by backend.
throw new InvalidCastException(
$"Cannot read oid {oid} into composite field {field.Name} with oid {field.PgTypeId}. " +
$"This could be caused by a DDL change after this DataSource loaded its types, or a difference between column order of table composites between backends, make sure these line up identically.");
}

public override Size GetSize(SizeContext context, T value, ref object? writeState)
{
var arrayPool = ArrayPool<ElementState>.Shared;
Expand Down
9 changes: 9 additions & 0 deletions test/Npgsql.Tests/Types/CompositeHandlerTests.Read.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ public Task Read_type_with_more_parameters_than_attributes_throws() =>
Read(new TypeWithMoreParametersThanAttributes(TheAnswer, HelloSlonik), (execute, expected) =>
Assert.That(() => execute(), Throws.Exception.TypeOf<InvalidCastException>().With.Property("InnerException").TypeOf<InvalidOperationException>()));

[Test]
public Task Read_type_with_nullable_property_set_to_null() =>
Read(new TypeWithNullableProperty { IntValue = TheAnswer }, (execute, expected) =>
{
var actual = execute();
Assert.That(actual.IntValue, Is.EqualTo(expected.IntValue));
Assert.That(actual.StringValue, Is.Null);
});

[Test]
public Task Read_type_with_one_parameter() =>
Read(new TypeWithOneParameter(1), (execute, expected) => Assert.That(execute().Value1, Is.EqualTo(expected.Value1)));
Expand Down
9 changes: 9 additions & 0 deletions test/Npgsql.Tests/Types/CompositeHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ public class TypeWithTwoParametersReversed(string stringValue, int intValue) : I
public string? StringValue { get; } = stringValue;
}

public class TypeWithNullableProperty : IComposite
{
public int IntValue { get; set; }
public string? StringValue { get; set; }

public string GetAttributes() => "int_value integer, string_value text";
public string GetValues() => $"{IntValue}, NULL";
}

public class TypeWithNineParameters(
int value1,
int value2,
Expand Down