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
19 changes: 15 additions & 4 deletions src/Npgsql.NodaTime/Internal/LegacyConverters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace Npgsql.NodaTime.Internal;

sealed class LegacyTimestampTzZonedDateTimeConverter(DateTimeZone dateTimeZone, bool dateTimeInfinityConversions)
sealed class LegacyTimestampTzZonedDateTimeConverter(bool dateTimeInfinityConversions)
: PgBufferedConverter<ZonedDateTime>
{
public override ConverterDescriptor GetDescriptor(in DescriptorContext context)
Expand All @@ -17,7 +17,7 @@ public override ZonedDateTime Read(PgReader reader)
if (dateTimeInfinityConversions && (instant == Instant.MaxValue || instant == Instant.MinValue))
throw new InvalidCastException("Infinity values not supported for timestamp with time zone");

return instant.InZone(dateTimeZone);
return instant.InZone(ResolveTimeZone(reader.ConversionContext));
}

public override void Write(PgWriter writer, ZonedDateTime value)
Expand All @@ -28,9 +28,20 @@ public override void Write(PgWriter writer, ZonedDateTime value)

writer.WriteInt64(EncodeInstant(instant, dateTimeInfinityConversions));
}

internal static DateTimeZone ResolveTimeZone(PgConversionContext conversionContext)
{
var tz = conversionContext.TimeZone
?? throw new InvalidOperationException("Reading 'timestamp with time zone' requires a session TimeZone; no connection is in scope.");
if (string.Equals(tz, "localtime", StringComparison.OrdinalIgnoreCase))
throw new TimeZoneNotFoundException(
"The special PostgreSQL timezone 'localtime' is not supported when reading values of type 'timestamp with time zone'. " +
"Please specify a real timezone in 'postgresql.conf' on the server, or set the 'PGTZ' environment variable on the client.");
return DateTimeZoneProviders.Tzdb[tz];
Comment thread
NinoFloris marked this conversation as resolved.
}
Comment thread
NinoFloris marked this conversation as resolved.
}

sealed class LegacyTimestampTzOffsetDateTimeConverter(DateTimeZone dateTimeZone, bool dateTimeInfinityConversions)
sealed class LegacyTimestampTzOffsetDateTimeConverter(bool dateTimeInfinityConversions)
: PgBufferedConverter<OffsetDateTime>
{
public override ConverterDescriptor GetDescriptor(in DescriptorContext context)
Expand All @@ -42,7 +53,7 @@ public override OffsetDateTime Read(PgReader reader)
if (dateTimeInfinityConversions && (instant == Instant.MaxValue || instant == Instant.MinValue))
throw new InvalidCastException("Infinity values not supported for timestamp with time zone");

return instant.InZone(dateTimeZone).ToOffsetDateTime();
return instant.InZone(LegacyTimestampTzZonedDateTimeConverter.ResolveTimeZone(reader.ConversionContext)).ToOffsetDateTime();
}

public override void Write(PgWriter writer, OffsetDateTime value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,10 @@ static TypeInfoMappingCollection AddMappings(TypeInfoMappingCollection mappings)
mapping.CreateInfo(options, new InstantConverter(options.EnableDateTimeInfinityConversions)), isDefault: true);
mappings.AddStructType<ZonedDateTime>(TimestampTzDataTypeName,
static (options, mapping, _) =>
mapping.CreateInfo(options, new LegacyTimestampTzZonedDateTimeConverter(
DateTimeZoneProviders.Tzdb[options.TimeZone], options.EnableDateTimeInfinityConversions)));
mapping.CreateInfo(options, new LegacyTimestampTzZonedDateTimeConverter(options.EnableDateTimeInfinityConversions)));
mappings.AddStructType<OffsetDateTime>(TimestampTzDataTypeName,
static (options, mapping, _) =>
mapping.CreateInfo(options, new LegacyTimestampTzOffsetDateTimeConverter(
DateTimeZoneProviders.Tzdb[options.TimeZone], options.EnableDateTimeInfinityConversions)));
mapping.CreateInfo(options, new LegacyTimestampTzOffsetDateTimeConverter(options.EnableDateTimeInfinityConversions)));
}
else
{
Expand Down
72 changes: 51 additions & 21 deletions src/Npgsql/BackendMessages/RowDescriptionMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@

namespace Npgsql.BackendMessages;

readonly struct ReadConversionContext(PgConcreteTypeInfo typeInfo, PgFieldBinding binding)
readonly struct ReadConversionContext(PgConcreteTypeInfo typeInfo, PgFieldBinding binding, PgConversionContext? sourceContext)
{
public bool IsDefault => TypeInfo is null;
public PgConcreteTypeInfo TypeInfo { get; } = typeInfo;
public PgFieldBinding Binding { get; } = binding;
/// <summary>The PgConversionContext this entry's binding was resolved against — or null when the
/// binding is context-invariant (BufferRequirement stable across rotations). Callers that cache
/// ReadConversionContext across calls compare this against the current connector context and re-bind
/// only when non-null and mismatching; invariant bindings stay valid indefinitely.</summary>
public PgConversionContext? SourceContext { get; } = sourceContext;
}

/// <summary>
Expand Down Expand Up @@ -136,8 +141,8 @@ public FieldDescription this[int ordinal]
}

[MethodImpl(MethodImplOptions.NoInlining)]
internal void GetConversionContext(int ordinal, Type type, ref ReadConversionContext result)
=> this[ordinal].GetConversionContext(type, ref result);
internal void GetConversionContext(int ordinal, PgConversionContext conversionContext, Type type, ref ReadConversionContext result)
=> this[ordinal].GetConversionContext(conversionContext, type, ref result);

internal void SetColumnInfoCache(ReadOnlySpan<ReadConversionContext> values)
{
Expand Down Expand Up @@ -311,19 +316,27 @@ internal void Populate(

internal PostgresType PostgresType { get; private set; }

internal Type FieldType => ObjectConversionContext.TypeInfo.Type;
internal Type GetFieldType(PgConversionContext conversionContext) => GetObjectConversionContext(conversionContext).TypeInfo.Type;

ReadConversionContext _objectConversionContext;
internal ReadConversionContext ObjectConversionContext
// Returns the cached object-typed binding, lazy-initializing via the supplied <paramref name="conversionContext"/>
// on first access. Threaded rather than stashed because the per-connector context reference rotates on
// ParameterStatus updates (client_encoding / TimeZone) and the FieldDescription itself persists long
// enough (prepared statements, connector-owned RowDescriptions) for that to matter. Cache hit requires
// the supplied context match the one the binding was resolved against — non-invariant converters can
// have context-dependent BufferRequirement.
internal ReadConversionContext GetObjectConversionContext(PgConversionContext conversionContext)
{
get
{
if (!_objectConversionContext.IsDefault)
return _objectConversionContext;

GetInfoAndBind(null, ref _objectConversionContext);
// Cache hit when entry is present AND either the cached binding is invariant (SourceContext null,
// never goes stale) or the SourceContext matches the live connector context.
if (!_objectConversionContext.IsDefault
&& (_objectConversionContext.SourceContext is null
|| ReferenceEquals(_objectConversionContext.SourceContext, conversionContext)))
return _objectConversionContext;
}

_objectConversionContext = default;
GetInfoAndBind(conversionContext, null, ref _objectConversionContext);
Comment thread
NinoFloris marked this conversation as resolved.
return _objectConversionContext;
}

internal PgSerializerOptions _serializerOptions;
Expand All @@ -334,8 +347,8 @@ internal FieldDescription Clone()
return field;
}

internal void GetConversionContext(Type type, ref ReadConversionContext result) => GetInfoAndBind(type, ref result);
void GetInfoAndBind(Type? type, ref ReadConversionContext result)
internal void GetConversionContext(PgConversionContext conversionContext, Type type, ref ReadConversionContext result) => GetInfoAndBind(conversionContext, type, ref result);
void GetInfoAndBind(PgConversionContext conversionContext, Type? type, ref ReadConversionContext result)
{
Debug.Assert(result.IsDefault || (
ReferenceEquals(_serializerOptions, result.TypeInfo.Options) && (
Expand All @@ -344,10 +357,27 @@ void GetInfoAndBind(Type? type, ref ReadConversionContext result)
result.TypeInfo.PgTypeId == _serializerOptions.ToCanonicalTypeId(PostgresType))
), "Cache is bleeding over");

if (result is { IsDefault: false, TypeInfo.Type: var typeToConvert } && typeToConvert == type)
// Cache hit requires source-context match — except for invariant bindings (SourceContext null),
// which stay valid across rotations. Non-invariant converters' BufferRequirement is
// context-dependent, so reusing a stale binding would give wrong sizing after rotation.
if (result is { IsDefault: false, TypeInfo.Type: var typeToConvert } && typeToConvert == type
&& (result.SourceContext is null || ReferenceEquals(result.SourceContext, conversionContext)))
return;

var objectInfo = DataFormat is DataFormat.Text && type is not null ? ObjectConversionContext : _objectConversionContext;
// Text + typed case routes through GetObjectConversionContext (which validates SourceContext).
// For the direct-read fast path, accept the cached binding when invariant (SourceContext null) or
// when its SourceContext matches the live one — otherwise a binding bound under a rotated context
// could be reused with stale BufferRequirements.
ReadConversionContext objectInfo;
if (DataFormat is DataFormat.Text && type is not null)
objectInfo = GetObjectConversionContext(conversionContext);
else if (!_objectConversionContext.IsDefault
&& (_objectConversionContext.SourceContext is null
|| ReferenceEquals(_objectConversionContext.SourceContext, conversionContext)))
objectInfo = _objectConversionContext;
else
objectInfo = default;

if (objectInfo.TypeInfo is not null && (typeof(object) == type || objectInfo.TypeInfo.Type == type))
{
result = objectInfo;
Expand Down Expand Up @@ -376,8 +406,8 @@ void Core(Type? type, out ReadConversionContext lastReadConversionContext)
if (!concreteTypeInfo.SupportsReading)
AdoSerializerHelpers.ThrowReadingNotSupported(type, _serializerOptions, _serializerOptions.TextPgTypeId, resolved: true);

binding = concreteTypeInfo.BindField(DataFormat.Text);
lastReadConversionContext = new(concreteTypeInfo, binding);
binding = concreteTypeInfo.BindField(conversionContext, DataFormat.Text);
lastReadConversionContext = new(concreteTypeInfo, binding, binding.IsBindingInvariant ? null : conversionContext);
break;
}
case DataFormat.Binary or DataFormat.Text:
Expand All @@ -389,8 +419,8 @@ void Core(Type? type, out ReadConversionContext lastReadConversionContext)
AdoSerializerHelpers.ThrowReadingNotSupported(type, _serializerOptions, _serializerOptions.ToCanonicalTypeId(PostgresType), resolved: true);

// If we don't support the DataFormat we'll just throw.
binding = concreteTypeInfo.BindField(DataFormat);
lastReadConversionContext = new(concreteTypeInfo, binding);
binding = concreteTypeInfo.BindField(conversionContext, DataFormat);
lastReadConversionContext = new(concreteTypeInfo, binding, binding.IsBindingInvariant ? null : conversionContext);
break;
}
default:
Expand All @@ -402,7 +432,7 @@ void Core(Type? type, out ReadConversionContext lastReadConversionContext)
// We delay initializing ObjectOrDefaultInfo until after the first lookup (unless it is itself the first lookup).
// When passed in an unsupported type it allows the error to be more specific, instead of just having object/null to deal with.
if (_objectConversionContext.TypeInfo is null && type is not null)
_ = ObjectConversionContext;
_ = GetObjectConversionContext(conversionContext);
}
}

Expand Down
37 changes: 22 additions & 15 deletions src/Npgsql/Internal/Composites/Metadata/CompositeFieldInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ abstract class CompositeFieldInfo

/// <summary>True iff the field's concrete converter returned an invariant descriptor at probe time.</summary>
/// <remarks>Provider-backed fields stay <c>false</c> (re-resolved at bind time).</remarks>
public bool IsInvariant { get; private set; }
public bool IsDescriptorInvariant { get; private set; }

/// <summary>
/// CompositeFieldInfo constructor.
Expand Down Expand Up @@ -48,11 +48,11 @@ private protected CompositeFieldInfo(string name, PgTypeInfo typeInfo, PgTypeId
nameof(typeInfo));

var fieldDescriptor = binaryConverter.GetDescriptor(new() { ConversionContext = PgConversionContext.Empty });
IsInvariant = fieldDescriptor.IsInvariant;
IsDescriptorInvariant = fieldDescriptor.IsInvariant;
// Only cache requirements when the descriptor is invariant; otherwise the probed value is stale
// relative to any context the inner converter may read, and GetReadInfo / GetWriteInfo re-resolve
// via the converter directly against the live context.
if (IsInvariant)
if (IsDescriptorInvariant)
_binaryBufferRequirements = fieldDescriptor.BufferRequirements;
ConcreteTypeInfo = direct;
_concreteBinaryConverter = binaryConverter;
Expand All @@ -78,23 +78,23 @@ static void ThrowMissingBinarySlot(string fieldName)
=> ThrowHelper.ThrowInvalidOperationException(
$"Composite field '{fieldName}' resolved to a concrete type info without a binary converter; composite fields require binary-format support.");

public PgConverter GetReadInfo(out Size readRequirement)
public PgConverter GetReadInfo(PgConversionContext conversionContext, out Size readRequirement)
{
var concreteTypeInfo = ConcreteTypeInfo ?? PgTypeInfo.MakeConcreteForField(new ProviderFieldContext { Name = Name });
if (!concreteTypeInfo.SupportsReading)
AdoSerializerHelpers.ThrowReadingNotSupported(PgTypeInfo.Type, PgTypeInfo.Options, concreteTypeInfo.PgTypeId, resolved: true);

if (!IsProviderBacked)
{
readRequirement = IsInvariant
readRequirement = IsDescriptorInvariant
? _binaryBufferRequirements.Read
: _concreteBinaryConverter.GetDescriptor(new() { ConversionContext = PgTypeInfo.Options.ConversionContext }).BufferRequirements.Read;
: _concreteBinaryConverter.GetDescriptor(new() { ConversionContext = conversionContext }).BufferRequirements.Read;
return _concreteBinaryConverter;
}

// Provider-resolved concrete: validate the binary slot is filled. TryBindField gates on slot presence
// and surfaces the binding's converter so we don't have to redo the slot pick.
if (!concreteTypeInfo.TryBindField(DataFormat.Binary, out var binding))
if (!concreteTypeInfo.TryBindField(conversionContext, DataFormat.Binary, out var binding))
ThrowMissingBinarySlot(Name);
readRequirement = binding.BufferRequirement;
return binding.Converter;
Expand All @@ -116,7 +116,7 @@ public PgConverter GetWriteInfo(object instance, in BindContext nestingContext,
if (!ConcreteTypeInfo.SupportsWriting)
AdoSerializerHelpers.ThrowWritingNotSupported(PgTypeInfo.Type, PgTypeInfo.Options, ConcreteTypeInfo.PgTypeId, resolved: true);
converter = _concreteBinaryConverter;
var reqs = IsInvariant
var reqs = IsDescriptorInvariant
? _binaryBufferRequirements
: _concreteBinaryConverter.GetDescriptor(new() { ConversionContext = nestingContext.ConversionContext }).BufferRequirements;
ctx = BindContext.CreateUnchecked(DataFormat.Binary, reqs.Write, reqs.IsBindOptional, nestingContext.ConversionContext);
Expand Down Expand Up @@ -164,9 +164,15 @@ public PgConverter GetDefaultWriteInfo(out Size writeRequirement)
// compiler what it needs to drop the null-forgiving.
if (IsProviderBacked)
ThrowHelper.ThrowInvalidOperationException("GetDefaultWriteInfo is not supported for provider-backed fields.");
writeRequirement = IsInvariant
? _binaryBufferRequirements.Write
: _concreteBinaryConverter.GetDescriptor(new() { ConversionContext = PgTypeInfo.Options.ConversionContext }).BufferRequirements.Write;
// GetDefaultWriteInfo only runs on the Exact-sized composite fast path; non-invariant fields
// contribute Streaming via GetBinaryRequirements, which prevents Exact, so we should never get
// here with a non-invariant field. Promote to a runtime throw rather than Debug.Assert — if the
// upstream invariant ever breaks, _binaryBufferRequirements.Write would default to zero and we'd
// silently emit a zero-sized write that corrupts protocol framing.
if (!IsDescriptorInvariant)
ThrowHelper.ThrowInvalidOperationException(
"GetDefaultWriteInfo invoked on a non-invariant field; the Exact-sized composite path should have excluded it.");
writeRequirement = _binaryBufferRequirements.Write;
return _concreteBinaryConverter;
}

Expand All @@ -188,12 +194,13 @@ public PgConverter GetDefaultWriteInfo(out Size writeRequirement)
/// </summary>
public BufferRequirements GetBinaryRequirements()
{
if (IsProviderBacked)
// Non-invariant fields contribute Streaming — same shape as provider-backed. We're called at
// composite construction with no live ConversionContext, so we can't honestly probe; the composite
// accommodates with Streaming and the per-call paths re-resolve via GetReadInfo.
if (IsProviderBacked || !IsDescriptorInvariant)
return BufferRequirements.Streaming;

var reqs = IsInvariant
? _binaryBufferRequirements
: _concreteBinaryConverter.GetDescriptor(new() { ConversionContext = PgTypeInfo.Options.ConversionContext }).BufferRequirements;
var reqs = _binaryBufferRequirements;
var readReq = ConcreteTypeInfo.SupportsReading ? reqs.Read : Size.Unknown;
var writeReq = ConcreteTypeInfo.SupportsWriting ? reqs.Write : Size.Unknown;
return BufferRequirements.Create(readReq, writeReq, optionalBind: reqs.IsBindOptional);
Expand Down
6 changes: 3 additions & 3 deletions src/Npgsql/Internal/Converters/CompositeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public CompositeConverter(CompositeInfo<T> composite)

req = req.Combine(fieldReqs);
// Provider-backed fields are inherently non-invariant (resolved per value at bind time); non-provider
// fields propagate their probe-time IsInvariant. AND across all fields gives the composite's claim.
allFieldsInvariant &= field.IsInvariant && !field.IsProviderBacked;
// fields propagate their probe-time IsDescriptorInvariant. AND across all fields gives the composite's claim.
allFieldsInvariant &= field.IsDescriptorInvariant && !field.IsProviderBacked;
}
_allFieldsInvariant = allFieldsInvariant;

Expand Down Expand Up @@ -108,7 +108,7 @@ async ValueTask<T> Read(bool async, PgReader reader, CancellationToken cancellat
field.ReadDbNull(builder);
else
{
var converter = field.GetReadInfo(out var readRequirement);
var converter = field.GetReadInfo(reader.ConversionContext, out var readRequirement);
var scope = await reader.BeginNestedRead(async, length, readRequirement, cancellationToken).ConfigureAwait(false);
try
{
Expand Down
Loading
Loading