From 899e2bb3a03dde6888d7ae45f15071415bf354e4 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 24 Feb 2025 23:37:26 +0200 Subject: [PATCH 01/17] Bump version to 9.0.4 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 812c9a3412..69fd8f5464 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@  - 9.0.3 + 9.0.4 latest true enable From ded0c27d8ff9cbd94f504858b84ac2e4168f0cf8 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Tue, 4 Mar 2025 09:46:11 +0100 Subject: [PATCH 02/17] Remove dotnet SDK version from CI (use global.json) (#6037) (cherry picked from commit 061a5f2059b7fb5132b0cc8bf332a2255dccef7c) --- .github/workflows/build.yml | 14 +++----------- .github/workflows/codeql-analysis.yml | 5 +---- .github/workflows/native-aot.yml | 11 ++--------- .github/workflows/rich-code-nav.yml | 5 +---- global.json | 2 +- 5 files changed, 8 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index add3743612..5162a2bb45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,6 @@ concurrency: cancel-in-progress: true env: - dotnet_sdk_version: '9.0.100' postgis_version: 3 DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true # Windows comes with PG pre-installed, and defines the PGPASSWORD environment variable. Remove it as it interferes @@ -69,10 +68,7 @@ jobs: ${{ runner.os }}-nuget- - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: | - ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Build run: dotnet build -c ${{ matrix.config }} @@ -354,9 +350,7 @@ jobs: ${{ runner.os }}-nuget- - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Pack run: dotnet pack Npgsql.sln --configuration Release --property:PackageOutputPath="$PWD/nupkgs" --version-suffix "ci.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" -p:ContinuousIntegrationBuild=true @@ -388,9 +382,7 @@ jobs: uses: actions/checkout@v4 - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Pack run: dotnet pack Npgsql.sln --configuration Release --property:PackageOutputPath="$PWD/nupkgs" -p:ContinuousIntegrationBuild=true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 013421b14d..9fa5eeb8e1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,6 @@ concurrency: cancel-in-progress: true env: - dotnet_sdk_version: '9.0.100' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true jobs: @@ -66,9 +65,7 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Build run: dotnet build -c Release diff --git a/.github/workflows/native-aot.yml b/.github/workflows/native-aot.yml index 2f1b94a0d5..25514352ce 100644 --- a/.github/workflows/native-aot.yml +++ b/.github/workflows/native-aot.yml @@ -15,7 +15,6 @@ concurrency: cancel-in-progress: true env: - dotnet_sdk_version: '9.0.100' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true AOT_Compat: | param([string]$targetFramework) @@ -108,10 +107,7 @@ jobs: ${{ runner.os }}-nuget- - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: | - ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Write script run: echo "$AOT_Compat" > test-aot-compatibility.ps1 @@ -145,10 +141,7 @@ jobs: ${{ runner.os }}-nuget- - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: | - ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Start PostgreSQL run: | diff --git a/.github/workflows/rich-code-nav.yml b/.github/workflows/rich-code-nav.yml index 278b05c95a..b25a971133 100644 --- a/.github/workflows/rich-code-nav.yml +++ b/.github/workflows/rich-code-nav.yml @@ -4,7 +4,6 @@ on: workflow_dispatch: env: - dotnet_sdk_version: '9.0.100' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true jobs: @@ -24,9 +23,7 @@ jobs: ${{ runner.os }}-nuget- - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v4.1.0 - with: - dotnet-version: ${{ env.dotnet_sdk_version }} + uses: actions/setup-dotnet@v4.3.0 - name: Build run: dotnet build Npgsql.sln --configuration Debug diff --git a/global.json b/global.json index 67db748a1b..30d15aae0f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", + "version": "9.0.200", "rollForward": "latestMajor", "allowPrerelease": "false" } From 1b99186d92678985fd6a94a1ec185a3b0b5ae3a9 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Wed, 19 Mar 2025 17:04:39 +0100 Subject: [PATCH 03/17] Update copyright to 2025 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 69fd8f5464..4f9ffeac69 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true true - Copyright 2024 © The Npgsql Development Team + Copyright 2025 © The Npgsql Development Team Npgsql PostgreSQL https://github.com/npgsql/npgsql From 900e9be109ccb6dd48498640de4b358dab258761 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sat, 22 Mar 2025 15:03:15 +0000 Subject: [PATCH 04/17] NpgsqlParameterCollection.Clone() should set correct collection instance (#6066) fix #6065 (cherry picked from commit e8ce19fe2aea5df32f508ba2b1cc15a1307d1a22) --- src/Npgsql/NpgsqlParameterCollection.cs | 2 +- test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Npgsql/NpgsqlParameterCollection.cs b/src/Npgsql/NpgsqlParameterCollection.cs index 8031fd7efc..f1a52a5bea 100644 --- a/src/Npgsql/NpgsqlParameterCollection.cs +++ b/src/Npgsql/NpgsqlParameterCollection.cs @@ -664,7 +664,7 @@ internal void CloneTo(NpgsqlParameterCollection other) foreach (var param in InternalList) { var newParam = param.Clone(); - newParam.Collection = this; + newParam.Collection = other; other.InternalList.Add(newParam); } diff --git a/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs b/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs index 6c09b7b708..e2c7ba364c 100644 --- a/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs +++ b/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs @@ -4,6 +4,7 @@ using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace Npgsql.Tests; @@ -320,6 +321,18 @@ public void Clean_name() Assert.AreEqual(NpgsqlParameter.PositionalName, param.ParameterName); } + [Test] + public void Clone_sets_correct_collection() + { + var cmd = new NpgsqlCommand(); + cmd.Parameters.Add(new NpgsqlParameter { TypedValue = 42 }); + Assert.AreSame(cmd.Parameters, cmd.Parameters.Single().Collection); + + cmd = cmd.Clone(); + Assert.AreSame(cmd.Parameters, cmd.Parameters.Single().Collection); + } + + public NpgsqlParameterCollectionTests(CompatMode compatMode) { _compatMode = compatMode; From 5cbd025a9d78422752309831dee3ab78da85d5e2 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Wed, 26 Mar 2025 14:17:51 +0300 Subject: [PATCH 05/17] Fix adding to hash lookup while renaming an unnamed parameter (#6073) Fixes #6067 (cherry picked from commit ef219b73f12b040010edb8362522db661a5f5969) --- src/Npgsql/NpgsqlParameterCollection.cs | 2 +- .../NpgsqlParameterCollectionTests.cs | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Npgsql/NpgsqlParameterCollection.cs b/src/Npgsql/NpgsqlParameterCollection.cs index f1a52a5bea..288956914d 100644 --- a/src/Npgsql/NpgsqlParameterCollection.cs +++ b/src/Npgsql/NpgsqlParameterCollection.cs @@ -143,7 +143,7 @@ internal void ChangeParameterName(NpgsqlParameter parameter, string? value) var oldTrimmedName = parameter.TrimmedName; parameter.ChangeParameterName(value); - if (_caseInsensitiveLookup is null || _caseInsensitiveLookup.Count == 0) + if (_caseInsensitiveLookup is null) return; var index = IndexOf(parameter); diff --git a/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs b/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs index e2c7ba364c..f6a188817b 100644 --- a/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs +++ b/test/Npgsql.Tests/NpgsqlParameterCollectionTests.cs @@ -71,6 +71,34 @@ public void Hash_lookup_parameter_rename_bug() Assert.That(command.Parameters.IndexOf("a_new_name"), Is.GreaterThanOrEqualTo(0)); } + [Test] + [IssueLink("https://github.com/npgsql/npgsql/issues/6067")] + public void Hash_lookup_unnamed_parameter_rename_bug() + { + if (_compatMode == CompatMode.TwoPass) + return; + + using var command = new NpgsqlCommand(); + + for (var i = 0; i < 3; i++) + { + // Put plenty of parameters in the collection to turn on hash lookup functionality. + for (var j = 0; j < LookupThreshold; j++) + { + // Create and add an unnamed parameter before renaming it + var parameter = command.CreateParameter(); + command.Parameters.Add(parameter); + parameter.ParameterName = $"{j}"; + } + + // Make sure hash lookup is generated. + Assert.AreEqual(command.Parameters["3"].ParameterName, "3"); + + // Remove all parameters to clear hash lookup + command.Parameters.Clear(); + } + } + [Test] public void Remove_duplicate_parameter([Values(LookupThreshold, LookupThreshold - 2)] int count) { From 8c4bfa40a30a3bea56c61f7271fb2577be05c1ef Mon Sep 17 00:00:00 2001 From: kurnakovv <59327306+kurnakovv@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:08:53 +0900 Subject: [PATCH 06/17] Update LICENSE date (2024 -> 2025) (#6082) (cherry picked from commit cf9d2433bc653f363705296b5804a9658bb49083) --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index a74ee166ce..c551cb7b0c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2002-2024, Npgsql +Copyright (c) 2002-2025, Npgsql Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement From 308a1f352221c55b81bf325520b62b43ff1b1970 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Wed, 7 May 2025 13:49:01 +0300 Subject: [PATCH 07/17] Fix reading columns asynchronously via JsonNet plugin (#6109) Fixes #6108 (cherry picked from commit 4da52a03f441f23a4f5597ac106b7833ab4fbe90) --- src/Npgsql.Json.NET/Internal/JsonNetJsonConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Npgsql.Json.NET/Internal/JsonNetJsonConverter.cs b/src/Npgsql.Json.NET/Internal/JsonNetJsonConverter.cs index 5d75568f98..10126d25f9 100644 --- a/src/Npgsql.Json.NET/Internal/JsonNetJsonConverter.cs +++ b/src/Npgsql.Json.NET/Internal/JsonNetJsonConverter.cs @@ -51,7 +51,7 @@ static class JsonNetJsonConverter using var stream = reader.GetStream(); var mem = new MemoryStream(); if (async) - await stream.CopyToAsync(mem, Math.Min((int)mem.Length, 81920), cancellationToken).ConfigureAwait(false); + await stream.CopyToAsync(mem, Math.Min((int)stream.Length, 81920), cancellationToken).ConfigureAwait(false); else stream.CopyTo(mem); mem.Position = 0; From 7a007d9d0fcbfdb5952cb031807107da94115863 Mon Sep 17 00:00:00 2001 From: Nino Floris Date: Sun, 1 Jun 2025 12:49:53 +0200 Subject: [PATCH 08/17] Fixes #6107 missed should buffer in biginteger numeric converter (#6117) (cherry picked from commit 892774fda1c92b53064d775b59e49a8b204d2304) --- .../Internal/Converters/Primitive/NumericConverters.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Npgsql/Internal/Converters/Primitive/NumericConverters.cs b/src/Npgsql/Internal/Converters/Primitive/NumericConverters.cs index c43e90a1f7..16a3c67639 100644 --- a/src/Npgsql/Internal/Converters/Primitive/NumericConverters.cs +++ b/src/Npgsql/Internal/Converters/Primitive/NumericConverters.cs @@ -13,6 +13,9 @@ sealed class BigIntegerNumericConverter : PgStreamingConverter public override BigInteger Read(PgReader reader) { + if (reader.ShouldBuffer(sizeof(short))) + reader.Buffer(sizeof(short)); + var digitCount = reader.ReadInt16(); short[]? digitsFromPool = null; var digits = (digitCount <= StackAllocByteThreshold / sizeof(short) @@ -37,7 +40,9 @@ public override ValueTask ReadAsync(PgReader reader, CancellationTok static async ValueTask AsyncCore(PgReader reader, CancellationToken cancellationToken) { - await reader.BufferAsync(PgNumeric.GetByteCount(0), cancellationToken).ConfigureAwait(false); + if (reader.ShouldBuffer(sizeof(short))) + await reader.BufferAsync(sizeof(short), cancellationToken).ConfigureAwait(false); + var digitCount = reader.ReadInt16(); var digits = new ArraySegment(ArrayPool.Shared.Rent(digitCount), 0, digitCount); var value = ConvertTo(await NumericConverter.ReadAsync(reader, digits, cancellationToken).ConfigureAwait(false)); @@ -187,7 +192,7 @@ static T ConvertTo(in PgNumeric.Builder numeric) static class NumericConverter { - public static int DecimalBasedMaxByteCount = PgNumeric.GetByteCount(PgNumeric.Builder.MaxDecimalNumericDigits); + public static readonly int DecimalBasedMaxByteCount = PgNumeric.GetByteCount(PgNumeric.Builder.MaxDecimalNumericDigits); public static PgNumeric.Builder Read(PgReader reader, Span digits) { From 9b2b2bcb80caf5d73956243443cc4b27faade033 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Tue, 3 Jun 2025 11:21:58 +0300 Subject: [PATCH 09/17] Fix logging parameters with batches (#6079) Fixes #6078 (cherry picked from commit fe7f7755ed78b5292d38c890b110b76ca94e111f) --- src/Npgsql/LogMessages.cs | 4 ++-- src/Npgsql/NpgsqlCommand.cs | 5 ++-- src/Npgsql/Util/LoggingEnumerable.cs | 36 ++++++++++++++++++++++++++++ test/Npgsql.Tests/LoggingTests.cs | 22 ++++++++--------- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 src/Npgsql/Util/LoggingEnumerable.cs diff --git a/src/Npgsql/LogMessages.cs b/src/Npgsql/LogMessages.cs index 8d5f471c27..349b91b4b5 100644 --- a/src/Npgsql/LogMessages.cs +++ b/src/Npgsql/LogMessages.cs @@ -180,7 +180,7 @@ static partial class LogMessages Level = LogLevel.Debug, Message = "Executing batch: {BatchCommands}", SkipEnabledCheck = true)] - internal static partial void ExecutingBatchWithParameters(ILogger logger, (string CommandText, object[] Parameters)[] BatchCommands, int ConnectorId); + internal static partial void ExecutingBatchWithParameters(ILogger logger, (string CommandText, IEnumerable Parameters)[] BatchCommands, int ConnectorId); [LoggerMessage( EventId = NpgsqlEventId.CommandExecutionCompleted, @@ -209,7 +209,7 @@ static partial class LogMessages Message = "Batch execution completed (duration={DurationMs}ms): {BatchCommands}", SkipEnabledCheck = true)] internal static partial void BatchExecutionCompletedWithParameters( - ILogger logger, (string CommandText, object[] Parameters)[] BatchCommands, long DurationMs, int ConnectorId); + ILogger logger, (string CommandText, IEnumerable Parameters)[] BatchCommands, long DurationMs, int ConnectorId); [LoggerMessage( EventId = NpgsqlEventId.CancellingCommand, diff --git a/src/Npgsql/NpgsqlCommand.cs b/src/Npgsql/NpgsqlCommand.cs index 012ce4cf56..ef2669a84b 100644 --- a/src/Npgsql/NpgsqlCommand.cs +++ b/src/Npgsql/NpgsqlCommand.cs @@ -1823,6 +1823,7 @@ internal void LogExecutingCompleted(NpgsqlConnector connector, bool executing) { var logParameters = connector.LoggingConfiguration.IsParameterLoggingEnabled || connector.Settings.LogParameters; var logger = connector.LoggingConfiguration.CommandLogger; + Debug.Assert(executing ? logger.IsEnabled(LogLevel.Debug) : logger.IsEnabled(LogLevel.Information)); if (InternalBatchCommands.Count == 1) { @@ -1860,9 +1861,9 @@ internal void LogExecutingCompleted(NpgsqlConnector connector, bool executing) { if (logParameters) { - var commands = new (string, object[])[InternalBatchCommands.Count]; + var commands = new (string, IEnumerable)[InternalBatchCommands.Count]; for (var i = 0; i < InternalBatchCommands.Count; i++) - commands[i] = (InternalBatchCommands[i].FinalCommandText!, GetParametersForLogging(InternalBatchCommands[i])); + commands[i] = (InternalBatchCommands[i].FinalCommandText!, new LoggingEnumerable(GetParametersForLogging(InternalBatchCommands[i]))); if (executing) LogMessages.ExecutingBatchWithParameters(logger, commands, connector.Id); diff --git a/src/Npgsql/Util/LoggingEnumerable.cs b/src/Npgsql/Util/LoggingEnumerable.cs new file mode 100644 index 0000000000..eabc7ebdd5 --- /dev/null +++ b/src/Npgsql/Util/LoggingEnumerable.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; + +namespace Npgsql.Util; + +// For logging batches we have to use a wrapper for parameters, otherwise they're logged as object[]. See https://github.com/npgsql/npgsql/issues/6078. +sealed class LoggingEnumerable(IEnumerable wrappedEnumerable) : IEnumerable +{ + public IEnumerator GetEnumerator() => wrappedEnumerable.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)wrappedEnumerable).GetEnumerator(); + + public override string ToString() + { + var sb = new StringBuilder(); + + sb.Append('['); + + var appended = false; + + foreach (var o in wrappedEnumerable) + { + if (appended) + sb.Append(", "); + else + appended = true; + + sb.Append(o); + } + + sb.Append(']'); + + return sb.ToString(); + } +} diff --git a/test/Npgsql.Tests/LoggingTests.cs b/test/Npgsql.Tests/LoggingTests.cs index b9a566b6a8..76f13ab03c 100644 --- a/test/Npgsql.Tests/LoggingTests.cs +++ b/test/Npgsql.Tests/LoggingTests.cs @@ -143,8 +143,8 @@ public async Task Command_ExecuteScalar_multiple_statement_without_parameters() } var executingCommandEvent = listLoggerProvider.Log.Single(l => l.Id == NpgsqlEventId.CommandExecutionCompleted); - Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT 1, System.Object[]), (SELECT 2, System.Object[])]")); - var batchCommands = (IList<(string CommandText, object[] Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); + Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT 1, []), (SELECT 2, [])]")); + var batchCommands = (IList<(string CommandText, IEnumerable Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); Assert.That(batchCommands.Count, Is.EqualTo(2)); Assert.That(batchCommands[0].CommandText, Is.EqualTo("SELECT 1")); Assert.That(batchCommands[0].Parameters, Is.Empty); @@ -171,13 +171,13 @@ public async Task Command_ExecuteScalar_multiple_statement_with_parameters() } var executingCommandEvent = listLoggerProvider.Log.Single(l => l.Id == NpgsqlEventId.CommandExecutionCompleted); - Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT $1, System.Object[]), (SELECT $1, System.Object[])]")); - var batchCommands = (IList<(string CommandText, object[] Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); + Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT $1, [8]), (SELECT $1, [9])]")); + var batchCommands = (IList<(string CommandText, IEnumerable Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); Assert.That(batchCommands.Count, Is.EqualTo(2)); Assert.That(batchCommands[0].CommandText, Is.EqualTo("SELECT $1")); - Assert.That(batchCommands[0].Parameters[0], Is.EqualTo(8)); + Assert.That(batchCommands[0].Parameters.First(), Is.EqualTo(8)); Assert.That(batchCommands[1].CommandText, Is.EqualTo("SELECT $1")); - Assert.That(batchCommands[1].Parameters[0], Is.EqualTo(9)); + Assert.That(batchCommands[1].Parameters.First(), Is.EqualTo(9)); AssertLoggingStateDoesNotContain(executingCommandEvent, "Parameters"); if (!IsMultiplexing) @@ -256,21 +256,19 @@ public async Task Batch_ExecuteScalar_multiple_statements_with_parameters() var executingCommandEvent = listLoggerProvider.Log.Single(l => l.Id == NpgsqlEventId.CommandExecutionCompleted); - // Note: the message formatter of Microsoft.Extensions.Logging doesn't seem to handle arrays inside tuples, so we get the - // following ugliness (https://github.com/dotnet/runtime/issues/63165). Serilog handles this fine. - Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT $1, System.Object[]), (SELECT $1, 9, System.Object[])]")); + Assert.That(executingCommandEvent.Message, Does.Contain("Batch execution completed").And.Contains("[(SELECT $1, [8]), (SELECT $1, 9, [9])]")); AssertLoggingStateDoesNotContain(executingCommandEvent, "CommandText"); AssertLoggingStateDoesNotContain(executingCommandEvent, "Parameters"); if (!IsMultiplexing) AssertLoggingStateContains(executingCommandEvent, "ConnectorId", conn.ProcessID); - var batchCommands = (IList<(string CommandText, object[] Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); + var batchCommands = (IList<(string CommandText, IEnumerable Parameters)>)AssertLoggingStateContains(executingCommandEvent, "BatchCommands"); Assert.That(batchCommands.Count, Is.EqualTo(2)); Assert.That(batchCommands[0].CommandText, Is.EqualTo("SELECT $1")); - Assert.That(batchCommands[0].Parameters[0], Is.EqualTo(8)); + Assert.That(batchCommands[0].Parameters.First(), Is.EqualTo(8)); Assert.That(batchCommands[1].CommandText, Is.EqualTo("SELECT $1, 9")); - Assert.That(batchCommands[1].Parameters[0], Is.EqualTo(9)); + Assert.That(batchCommands[1].Parameters.First(), Is.EqualTo(9)); } [Test] From 9b9ca5533f291edbfbe457ade5853ece0566e977 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Fri, 20 Jun 2025 13:19:59 +0300 Subject: [PATCH 10/17] Fix returning null from KerberosUsernameProvider.GetUsername with concurrent calls (#6137) Fixes #6136 (cherry picked from commit e3921f213b251c80bc35f2622db9c7dc99626c68) --- src/Npgsql/KerberosUsernameProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Npgsql/KerberosUsernameProvider.cs b/src/Npgsql/KerberosUsernameProvider.cs index 3afb326548..c69409f360 100644 --- a/src/Npgsql/KerberosUsernameProvider.cs +++ b/src/Npgsql/KerberosUsernameProvider.cs @@ -11,9 +11,9 @@ namespace Npgsql; /// Launches MIT Kerberos klist and parses out the default principal from it. /// Caches the result. /// -sealed class KerberosUsernameProvider +static class KerberosUsernameProvider { - static bool _performedDetection; + static volatile bool _performedDetection; static string? _principalWithRealm; static string? _principalWithoutRealm; From f5df84025417eece7385087cb8acd26f3612b98b Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 23 Jun 2025 13:03:52 +0200 Subject: [PATCH 11/17] Add NpgsqlTsVector.Empty (#6145) Closes #6134 (cherry picked from commit 1b55ebc74d15ef9edff5ab661062de5cd4625a2c) --- src/Npgsql/NpgsqlTypes/NpgsqlTsVector.cs | 5 +++++ src/Npgsql/PublicAPI.Unshipped.txt | 1 + test/Npgsql.Tests/TypesTests.cs | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/src/Npgsql/NpgsqlTypes/NpgsqlTsVector.cs b/src/Npgsql/NpgsqlTypes/NpgsqlTsVector.cs index 2cf1bcb3f7..cb494e875e 100644 --- a/src/Npgsql/NpgsqlTypes/NpgsqlTsVector.cs +++ b/src/Npgsql/NpgsqlTypes/NpgsqlTsVector.cs @@ -11,6 +11,11 @@ namespace NpgsqlTypes; /// public sealed class NpgsqlTsVector : IEnumerable, IEquatable { + /// + /// Represents an empty tsvector. + /// + public static readonly NpgsqlTsVector Empty = new NpgsqlTsVector([], noCheck: true); + readonly List _lexemes; internal NpgsqlTsVector(List lexemes, bool noCheck = false) diff --git a/src/Npgsql/PublicAPI.Unshipped.txt b/src/Npgsql/PublicAPI.Unshipped.txt index 10d2965ba0..0402bc9c97 100644 --- a/src/Npgsql/PublicAPI.Unshipped.txt +++ b/src/Npgsql/PublicAPI.Unshipped.txt @@ -75,3 +75,4 @@ Npgsql.NpgsqlConnection.ReloadTypesAsync(System.Threading.CancellationToken canc *REMOVED*Npgsql.NpgsqlSlimDataSourceBuilder.MapComposite(string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.TypeMapping.INpgsqlTypeMapper! *REMOVED*Npgsql.NpgsqlSlimDataSourceBuilder.MapEnum(System.Type! clrType, string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.TypeMapping.INpgsqlTypeMapper! *REMOVED*Npgsql.NpgsqlSlimDataSourceBuilder.MapEnum(string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.TypeMapping.INpgsqlTypeMapper! +static readonly NpgsqlTypes.NpgsqlTsVector.Empty -> NpgsqlTypes.NpgsqlTsVector! diff --git a/test/Npgsql.Tests/TypesTests.cs b/test/Npgsql.Tests/TypesTests.cs index 113c08b954..1f6b0e8c55 100644 --- a/test/Npgsql.Tests/TypesTests.cs +++ b/test/Npgsql.Tests/TypesTests.cs @@ -86,6 +86,13 @@ public void TsQuery() } #pragma warning restore CS0618 // {NpgsqlTsVector,NpgsqlTsQuery}.Parse are obsolete + [Test] + public void TsVector_empty() + { + Assert.IsEmpty(NpgsqlTsVector.Empty); + Assert.AreEqual(string.Empty, NpgsqlTsVector.Empty.ToString()); + } + [Test] public void TsQueryEquatibility() { From 8aa2ce82ef097d3b931c34bdc612d1be65f417e2 Mon Sep 17 00:00:00 2001 From: 0MG-DEN <31481586+0MG-DEN@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:10:29 +0300 Subject: [PATCH 12/17] Compare normalized type names (#6011) Fixes #6010 (cherry picked from commit 016ae357277cf2cf65905b9a1bac76a4673753e4) --- src/Npgsql/Internal/TypeInfoMapping.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Npgsql/Internal/TypeInfoMapping.cs b/src/Npgsql/Internal/TypeInfoMapping.cs index afb5325590..c8439de6ac 100644 --- a/src/Npgsql/Internal/TypeInfoMapping.cs +++ b/src/Npgsql/Internal/TypeInfoMapping.cs @@ -71,7 +71,8 @@ public readonly struct TypeInfoMapping(Type type, string dataTypeName, TypeInfoF public Func? TypeMatchPredicate { get; init; } public bool TypeEquals(Type type) => TypeMatchPredicate?.Invoke(type) ?? Type == type; - public bool DataTypeNameEquals(string dataTypeName) + + private bool DataTypeNameEqualsCore(string dataTypeName) { var span = DataTypeName.AsSpan(); return Postgres.DataTypeName.IsFullyQualified(span) @@ -79,6 +80,18 @@ public bool DataTypeNameEquals(string dataTypeName) : span.Equals(Postgres.DataTypeName.ValidatedName(dataTypeName).UnqualifiedNameSpan, StringComparison.Ordinal); } + internal bool DataTypeNameEquals(DataTypeName dataTypeName) + { + var value = dataTypeName.Value; + return DataTypeNameEqualsCore(value); + } + + public bool DataTypeNameEquals(string dataTypeName) + { + var normalized = Postgres.DataTypeName.NormalizeName(dataTypeName); + return DataTypeNameEqualsCore(normalized); + } + string DebuggerDisplay { get @@ -125,7 +138,7 @@ public TypeInfoMappingCollection(IEnumerable items) { var looseTypeMatch = mapping.TypeMatchPredicate is { } pred ? pred(type) : type is null || mapping.Type == type; var typeMatch = type is not null && looseTypeMatch; - var dataTypeMatch = dataTypeName is not null && mapping.DataTypeNameEquals(dataTypeName.Value.Value); + var dataTypeMatch = dataTypeName is not null && mapping.DataTypeNameEquals(dataTypeName.Value); var matchRequirement = mapping.MatchRequirement; if (dataTypeMatch && typeMatch From 48ef266462d68f7714b17767a97fb2a8e4a36282 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Mon, 4 Aug 2025 17:10:00 +0300 Subject: [PATCH 13/17] Fix infinite consume on error with connection break (#6161) Fixes #6160 (cherry picked from commit c378bdbc141dedd00f4d67b2eaa246c5e8d92666) --- src/Npgsql/NpgsqlDataReader.cs | 8 +++++++- test/Npgsql.Tests/ReaderTests.cs | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Npgsql/NpgsqlDataReader.cs b/src/Npgsql/NpgsqlDataReader.cs index 4add2e970d..86963afd4a 100644 --- a/src/Npgsql/NpgsqlDataReader.cs +++ b/src/Npgsql/NpgsqlDataReader.cs @@ -985,7 +985,13 @@ async Task Consume(bool async, Exception? firstException = null) // Skip over the other result sets. Note that this does tally records affected from CommandComplete messages, and properly sets // state for auto-prepared statements - while (true) + // + // The only exception is when the connector is broken (which can happen in the middle of consuming) + // As then there is no point in going forward + // + // While we can also check our local state (State == Closed) + // It's probably better to rely on connector since it's private and its state can't be changed + while (!Connector.IsBroken) { try { diff --git a/test/Npgsql.Tests/ReaderTests.cs b/test/Npgsql.Tests/ReaderTests.cs index 7ee1aa6e11..9c912f1164 100644 --- a/test/Npgsql.Tests/ReaderTests.cs +++ b/test/Npgsql.Tests/ReaderTests.cs @@ -2371,6 +2371,41 @@ await pgMock Assert.That(conn.Connector!.State, Is.EqualTo(ConnectorState.Ready)); } + [Test, IssueLink("https://github.com/npgsql/npgsql/issues/6160")] + [Description("Consuming result set shouldn't go infinite in case connection is broken")] + public async Task Bug6160() + { + var csb = new NpgsqlConnectionStringBuilder(ConnectionString) + { + // Set to -1 to trigger immediate connection break on timeout + CancellationTimeout = -1, + CommandTimeout = 1 + }; + await using var postmasterMock = PgPostmasterMock.Start(csb.ConnectionString); + await using var dataSource = CreateDataSource(postmasterMock.ConnectionString); + await using var conn = await dataSource.OpenConnectionAsync(); + + var pgMock = await postmasterMock.WaitForServerConnection(); + await pgMock + .WriteParseComplete() + .WriteBindComplete() + .WriteRowDescription(new FieldDescription(Int4Oid)) + .WriteDataRow(new byte[4]) + .FlushAsync(); + + await using var cmd = new NpgsqlCommand("SELECT 1", conn); + await using (var reader = await cmd.ExecuteReaderAsync(Behavior | CommandBehavior.SingleRow)) + { + await reader.ReadAsync(); + // The second read will try to consume the whole resultset due to CommandBehavior.SingleRow + // Which will fail with timeout (and immediate connection break) since we didn't send anything else beside the first row + var ex = Assert.ThrowsAsync(async () => await reader.ReadAsync())!; + Assert.That(ex.InnerException, Is.TypeOf()); + + Assert.That(conn.State, Is.EqualTo(ConnectionState.Closed)); + } + } + #endregion #region Initialization / setup / teardown From e2b6731dba25c003c9504adec17e1e6967e8bf67 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Wed, 20 Aug 2025 12:45:24 +0300 Subject: [PATCH 14/17] Fix concurrent NpgsqlDataSource.Dispose and Bootstrap (#6116) Fixes #6115 (cherry picked from commit 6f8971ce5a811463c537376e8c70ffd9750cd396) --- src/Npgsql/NpgsqlDataSource.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Npgsql/NpgsqlDataSource.cs b/src/Npgsql/NpgsqlDataSource.cs index ce6db8f843..225722f3eb 100644 --- a/src/Npgsql/NpgsqlDataSource.cs +++ b/src/Npgsql/NpgsqlDataSource.cs @@ -500,8 +500,10 @@ protected virtual void DisposeBase() } _periodicPasswordProviderTimer?.Dispose(); - _setupMappingsSemaphore.Dispose(); MetricsReporter.Dispose(); + // We do not dispose _setupMappingsSemaphore explicitly, leaving it to finalizer + // Due to possible concurrent access, which might lead to deadlock + // See issue #6115 Clear(); } @@ -528,8 +530,10 @@ protected virtual async ValueTask DisposeAsyncBase() if (_periodicPasswordProviderTimer is not null) await _periodicPasswordProviderTimer.DisposeAsync().ConfigureAwait(false); - _setupMappingsSemaphore.Dispose(); MetricsReporter.Dispose(); + // We do not dispose _setupMappingsSemaphore explicitly, leaving it to finalizer + // Due to possible concurrent access, which might lead to deadlock + // See issue #6115 // TODO: async Clear, #4499 Clear(); From 4d7d09fd5dd64db54eae60a88a9a7cff021a5290 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Wed, 10 Sep 2025 14:32:51 +0300 Subject: [PATCH 15/17] Fix possible deadlock while asynchronously reading values from reader (#6202) Fixes #6190 (cherry picked from commit 7bbf43a67958dc735ac532cf2c9a62d15a927b3c) --- .../Internal/Converters/AsyncHelpers.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Npgsql/Internal/Converters/AsyncHelpers.cs b/src/Npgsql/Internal/Converters/AsyncHelpers.cs index ddd03a24be..bf85a06a9f 100644 --- a/src/Npgsql/Internal/Converters/AsyncHelpers.cs +++ b/src/Npgsql/Internal/Converters/AsyncHelpers.cs @@ -37,9 +37,17 @@ public abstract class CompletionSource public sealed class CompletionSource : CompletionSource { - AsyncValueTaskMethodBuilder _amb = AsyncValueTaskMethodBuilder.Create(); + AsyncValueTaskMethodBuilder _amb; - public ValueTask Task => _amb.Task; + public ValueTask Task { get; } + + public CompletionSource() + { + _amb = AsyncValueTaskMethodBuilder.Create(); + // AsyncValueTaskMethodBuilder's Task and SetResult aren't thread safe in regard to each other + // Which is why we access it prematurely + Task = _amb.Task; + } public void SetResult(T value) => _amb.SetResult(value); @@ -50,9 +58,17 @@ public override void SetException(Exception exception) public sealed class PoolingCompletionSource : CompletionSource { - PoolingAsyncValueTaskMethodBuilder _amb = PoolingAsyncValueTaskMethodBuilder.Create(); + PoolingAsyncValueTaskMethodBuilder _amb; - public ValueTask Task => _amb.Task; + public ValueTask Task { get; } + + public PoolingCompletionSource() + { + _amb = PoolingAsyncValueTaskMethodBuilder.Create(); + // PoolingAsyncValueTaskMethodBuilder's Task and SetResult aren't thread safe in regard to each other + // Which is why we access it prematurely + Task = _amb.Task; + } public void SetResult(T value) => _amb.SetResult(value); @@ -90,7 +106,7 @@ public CompletionSourceContinuation(object handle, delegate*(); OnCompletedWithSource(task.AsTask(), source, new(instance, &UnboxAndComplete)); return source.Task; @@ -111,7 +127,7 @@ public static unsafe ValueTask ReadAsObjectAsyncAsT(this PgConverter in if (task.IsCompletedSuccessfully) return new((T)task.Result); - // Otherwise we do one additional allocation, this allow us to share state machine codegen for all Ts. + // Otherwise we do one additional allocation, this allows us to share state machine codegen for all Ts. var source = new PoolingCompletionSource(); OnCompletedWithSource(task.AsTask(), source, new(instance, &UnboxAndComplete)); return source.Task; From c7bb8bcb94fe0d5d779f568583372d417b3216ed Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Sun, 5 Oct 2025 21:45:35 +0300 Subject: [PATCH 16/17] Add support for multiple client certificates (#6162) Fixes #6152 (cherry picked from commit 5d073dad647994754ad672c134474c0fd4869662) --- src/Npgsql/Internal/NpgsqlConnector.cs | 84 ++++++++++++++++++++------ 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Npgsql/Internal/NpgsqlConnector.cs b/src/Npgsql/Internal/NpgsqlConnector.cs index d30edff08c..5f8add7836 100644 --- a/src/Npgsql/Internal/NpgsqlConnector.cs +++ b/src/Npgsql/Internal/NpgsqlConnector.cs @@ -286,7 +286,7 @@ internal bool PostgresCancellationPerformed #pragma warning disable CA1859 // We're casting to IDisposable to not explicitly reference X509Certificate2 for NativeAOT // TODO: probably pointless now, needs to be rechecked - IDisposable? _certificate; + List? _certificates; #pragma warning restore CA1859 internal NpgsqlLoggingConfiguration LoggingConfiguration { get; } @@ -859,27 +859,61 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout, { var password = Settings.SslPassword; - X509Certificate2? cert = null; if (!string.Equals(Path.GetExtension(certPath), ".pfx", StringComparison.OrdinalIgnoreCase)) { // It's PEM time var keyPath = Settings.SslKey ?? PostgresEnvironment.SslKey ?? PostgresEnvironment.SslKeyDefault; - cert = string.IsNullOrEmpty(password) + + // With PEM certificates we might have multiple certificates in a single file + // Where the first one is a leaf (and it has to have a private key) + // And others are intermediate between it and CA cert + // To support this, we first load the leaf certificate with private key + // And then we load everything else including the leaf, but without private key + // And afterwards we just get rid of the duplicate + var firstClientCert = string.IsNullOrEmpty(password) ? X509Certificate2.CreateFromPemFile(certPath, keyPath) : X509Certificate2.CreateFromEncryptedPemFile(certPath, password, keyPath); + clientCertificates.Add(firstClientCert); + + clientCertificates.ImportFromPemFile(certPath); + clientCertificates[1].Dispose(); + clientCertificates.RemoveAt(1); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Windows crypto API has a bug with pem certs - // See #3650 - using var previousCert = cert; - cert = new X509Certificate2(cert.Export(X509ContentType.Pkcs12)); + for (var i = 0; i < clientCertificates.Count; i++) + { + var cert = clientCertificates[i]; + + // Windows crypto API has a bug with pem certs + // See #3650 + using var previousCert = cert; +#if NET9_0_OR_GREATER + cert = X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pkcs12), null); +#else + cert = new X509Certificate2(cert.Export(X509ContentType.Pkcs12)); +#endif + clientCertificates[i] = cert; + } } } - cert ??= new X509Certificate2(certPath, password); - clientCertificates.Add(cert); + // If it's empty, it's probably PFX + if (clientCertificates.Count == 0) + { +#if NET9_0_OR_GREATER + var certs = X509CertificateLoader.LoadPkcs12CollectionFromFile(certPath, password); + clientCertificates.AddRange(certs); +#else + var cert = new X509Certificate2(certPath, password); + clientCertificates.Add(cert); +#endif + } - _certificate = cert; + var certificates = new List(); + foreach (var certificate in clientCertificates) + certificates.Add(certificate); + _certificates = certificates; } try @@ -911,6 +945,21 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout, certificateValidationCallback = SslVerifyFullValidation; } +#if NET8_0_OR_GREATER + SslStreamCertificateContext? clientCertificateContext = null; + if (clientCertificates.Count > 0) + { + // SslClientAuthenticationOptions.ClientCertificates only sends trusted certificates or if they have private key + // Which makes us unable to send intermediate certificates + // Work around this by specifying the first certificate as target + // And others as additional + // See https://github.com/dotnet/runtime/issues/26323 + var clientCertificate = clientCertificates[0]; + clientCertificates.RemoveAt(0); + + clientCertificateContext = SslStreamCertificateContext.Create(clientCertificate, clientCertificates); + } +#endif var host = Host; #if !NET8_0_OR_GREATER @@ -930,7 +979,11 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout, var sslStreamOptions = new SslClientAuthenticationOptions { TargetHost = host, +#if NET8_0_OR_GREATER + ClientCertificateContext = clientCertificateContext, +#else ClientCertificates = clientCertificates, +#endif EnabledSslProtocols = SslProtocols.None, CertificateRevocationCheckMode = checkCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, RemoteCertificateValidationCallback = certificateValidationCallback, @@ -978,8 +1031,8 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout, } catch { - _certificate?.Dispose(); - _certificate = null; + _certificates?.ForEach(x => x.Dispose()); + _certificates = null; throw; } @@ -2291,11 +2344,8 @@ void Cleanup() PostgresParameters.Clear(); _currentCommand = null; - if (_certificate is not null) - { - _certificate.Dispose(); - _certificate = null; - } + _certificates?.ForEach(x => x.Dispose()); + _certificates = null; } void GenerateResetMessage() From 3b6c74c505c4dbc68a39b05e7440153b3bf511f4 Mon Sep 17 00:00:00 2001 From: Nikita Kazmin Date: Sun, 5 Oct 2025 21:47:19 +0300 Subject: [PATCH 17/17] Fix getting wrong schema with CommandBehavior.SchemaOnly and autoprepare (#6040) Fixes #6038 (cherry picked from commit 5ede53c5ba8cfc221b37fb2d0dcf00ad830dac80) --- src/Npgsql/NpgsqlCommand.cs | 19 +++++++-- src/Npgsql/NpgsqlDataReader.cs | 16 +++----- test/Npgsql.Tests/AutoPrepareTests.cs | 57 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/Npgsql/NpgsqlCommand.cs b/src/Npgsql/NpgsqlCommand.cs index ef2669a84b..70eae1ef1a 100644 --- a/src/Npgsql/NpgsqlCommand.cs +++ b/src/Npgsql/NpgsqlCommand.cs @@ -1120,11 +1120,24 @@ async Task WriteExecuteSchemaOnly(NpgsqlConnector connector, bool async, bool fl await new TaskSchedulerAwaitable(ConstrainedConcurrencyScheduler); var batchCommand = InternalBatchCommands[i]; + var pStatement = batchCommand.PreparedStatement; + + pStatement?.RefreshLastUsed(); + + Debug.Assert(batchCommand.FinalCommandText is not null); + + if (pStatement != null && !batchCommand.IsPreparing) + { + // Prepared, we already have the RowDescription + Debug.Assert(pStatement.IsPrepared); + continue; + } - if (batchCommand.PreparedStatement?.State == PreparedState.Prepared) - continue; // Prepared, we already have the RowDescription + // We may have a prepared statement that replaces an existing statement - close the latter first. + if (pStatement?.StatementBeingReplaced != null) + await connector.WriteClose(StatementOrPortal.Statement, pStatement.StatementBeingReplaced.Name!, async, cancellationToken).ConfigureAwait(false); - await connector.WriteParse(batchCommand.FinalCommandText!, batchCommand.StatementName, + await connector.WriteParse(batchCommand.FinalCommandText, batchCommand.StatementName, batchCommand.CurrentParametersReadOnly, async, cancellationToken).ConfigureAwait(false); await connector.WriteDescribe(StatementOrPortal.Statement, batchCommand.StatementName, async, cancellationToken).ConfigureAwait(false); diff --git a/src/Npgsql/NpgsqlDataReader.cs b/src/Npgsql/NpgsqlDataReader.cs index 86963afd4a..10c383f14d 100644 --- a/src/Npgsql/NpgsqlDataReader.cs +++ b/src/Npgsql/NpgsqlDataReader.cs @@ -713,7 +713,11 @@ async Task NextResultSchemaOnly(bool async, bool isConsuming = false, Canc break; case BackendMessageCode.RowDescription: // We have a resultset - RowDescription = _statements[StatementIndex].Description = (RowDescriptionMessage)msg; + // RowDescription messages are cached on the connector, but if we're auto-preparing, we need to + // clone our own copy which will last beyond the lifetime of this invocation. + RowDescription = _statements[StatementIndex].Description = preparedStatement == null + ? (RowDescriptionMessage)msg + : ((RowDescriptionMessage)msg).Clone(); Command.FixupRowDescription(RowDescription, StatementIndex == 0); break; default: @@ -734,17 +738,7 @@ async Task NextResultSchemaOnly(bool async, bool isConsuming = false, Canc // Found a resultset if (RowDescription is not null) - { - if (ColumnInfoCache?.Length >= ColumnCount) - Array.Clear(ColumnInfoCache, 0, ColumnCount); - else - { - if (ColumnInfoCache is { } cache) - ArrayPool.Shared.Return(cache, clearArray: true); - ColumnInfoCache = ArrayPool.Shared.Rent(ColumnCount); - } return true; - } } State = ReaderState.Consumed; diff --git a/test/Npgsql.Tests/AutoPrepareTests.cs b/test/Npgsql.Tests/AutoPrepareTests.cs index 00d9455147..b35fe7c5d3 100644 --- a/test/Npgsql.Tests/AutoPrepareTests.cs +++ b/test/Npgsql.Tests/AutoPrepareTests.cs @@ -538,6 +538,63 @@ public async Task SchemaOnly() await cmd.ExecuteScalarAsync(); } + [Test, IssueLink("https://github.com/npgsql/npgsql/issues/6038")] + public async Task Auto_prepared_schema_only_correct_schema() + { + await using var dataSource = CreateDataSource(csb => + { + csb.MaxAutoPrepare = 1; + csb.AutoPrepareMinUsages = 5; + }); + await using var connection = await dataSource.OpenConnectionAsync(); + var table1 = await CreateTempTable(connection, "foo int"); + var table2 = await CreateTempTable(connection, "bar int"); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT * FROM {table1}"; + for (var i = 0; i < 5; i++) + { + // Make sure we prepare the first query + await using (await cmd.ExecuteReaderAsync(CommandBehavior.SchemaOnly)) { } + } + + cmd.CommandText = $"SELECT * FROM {table2}"; + // The second query will load RowDescription, which is a singleton on NpgsqlConnector + // This shouldn't affect the first query, because we create a copy of RowDescription on prepare + await using (await cmd.ExecuteReaderAsync(CommandBehavior.SchemaOnly)) { } + + cmd.CommandText = $"SELECT * FROM {table1}"; + // If we indeed made a copy of RowDescription on prepare, we should get the column for the first query and not for the second + await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SchemaOnly | CommandBehavior.KeyInfo); + var columns = await reader.GetColumnSchemaAsync(); + Assert.That(columns.Count, Is.EqualTo(1)); + Assert.That(columns[0].ColumnName, Is.EqualTo("foo")); + } + + [Test] + public async Task Auto_prepared_schema_only_replace() + { + await using var dataSource = CreateDataSource(csb => + { + csb.MaxAutoPrepare = 1; + csb.AutoPrepareMinUsages = 5; + }); + await using var connection = await dataSource.OpenConnectionAsync(); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT 1"; + for (var i = 0; i < 5; i++) + { + await using (await cmd.ExecuteReaderAsync(CommandBehavior.SchemaOnly)) { } + } + + cmd.CommandText = "SELECT 2"; + for (var i = 0; i < 5; i++) + { + await using (await cmd.ExecuteReaderAsync(CommandBehavior.SchemaOnly)) { } + } + } + [Test] public async Task Auto_prepared_statement_invalidation() {