From aaae63a30839bfd7d5985a689de00d93311e88d6 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 14 Sep 2024 09:19:04 +0200 Subject: [PATCH] Add ConfigureDataSource() to NpgsqlDbContextOptionsBuilder Closes #2542 Closes #2704 --- .../Internal/NpgsqlOptionsExtension.cs | 20 ++ .../NpgsqlDbContextOptionsBuilder.cs | 24 ++- .../Internal/NpgsqlDataSourceManager.cs | 5 + .../NpgsqlRelationalConnectionTest.cs | 189 ++++++++++++++---- 4 files changed, 192 insertions(+), 46 deletions(-) diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs index 285ed2989..e4abca786 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs @@ -36,6 +36,11 @@ public virtual Version PostgresVersion public virtual bool IsPostgresVersionSet => _postgresVersion is not null; + /// + /// A lambda to configure Npgsql options on . + /// + public virtual Action? DataSourceBuilderAction { get; private set; } + /// /// The , or if a connection string or was used /// instead of a . @@ -126,6 +131,21 @@ public NpgsqlOptionsExtension(NpgsqlOptionsExtension copyFrom) public override int? MinBatchSize => base.MinBatchSize ?? 2; + /// + /// Creates a new instance with all options the same as for this instance, but with the given option changed. + /// It is unusual to call this method directly. Instead use . + /// + /// A lambda to configure Npgsql options on . + /// A new instance with the option changed. + public virtual NpgsqlOptionsExtension WithDataSourceConfiguration(Action dataSourceBuilderAction) + { + var clone = (NpgsqlOptionsExtension)Clone(); + + clone.DataSourceBuilderAction = dataSourceBuilderAction; + + return clone; + } + /// /// Creates a new instance with all options the same as for this instance, but with the given option changed. /// It is unusual to call this method directly. Instead use . diff --git a/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs b/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs index b0a8e9921..b3fb1c18d 100644 --- a/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs +++ b/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs @@ -18,6 +18,13 @@ public NpgsqlDbContextOptionsBuilder(DbContextOptionsBuilder optionsBuilder) { } + /// + /// Configures lower-level Npgsql options at the ADO.NET driver level. + /// + /// A lambda to configure Npgsql options on . + public virtual NpgsqlDbContextOptionsBuilder ConfigureDataSource(Action dataSourceBuilderAction) + => WithOption(e => e.WithDataSourceConfiguration(dataSourceBuilderAction)); + /// /// Connect to this database for administrative operations (creating/dropping databases). /// @@ -48,6 +55,8 @@ public virtual NpgsqlDbContextOptionsBuilder SetPostgresVersion(int major, int m public virtual NpgsqlDbContextOptionsBuilder UseRedshift(bool useRedshift = true) => WithOption(e => e.WithRedshift(useRedshift)); + #region MapRange + /// /// Maps a user-defined PostgreSQL range type for use. /// @@ -95,6 +104,10 @@ public virtual NpgsqlDbContextOptionsBuilder MapRange( string? subtypeName = null) => WithOption(e => e.WithUserRangeDefinition(rangeName, schemaName, subtypeClrType, subtypeName)); + #endregion MapRange + + #region MapEnum + /// /// Maps a PostgreSQL enum type for use. /// @@ -122,6 +135,8 @@ public virtual NpgsqlDbContextOptionsBuilder MapEnum( INpgsqlNameTranslator? nameTranslator = null) => WithOption(e => e.WithEnumMapping(clrType, enumName, schemaName, nameTranslator)); + #endregion MapEnum + /// /// Appends NULLS FIRST to all ORDER BY clauses. This is important for the tests which were written /// for SQL Server. Note that to fully implement null-first ordering indexes also need to be generated @@ -131,12 +146,13 @@ public virtual NpgsqlDbContextOptionsBuilder MapEnum( internal virtual NpgsqlDbContextOptionsBuilder ReverseNullOrdering(bool reverseNullOrdering = true) => WithOption(e => e.WithReverseNullOrdering(reverseNullOrdering)); - #region Authentication + #region Authentication (obsolete) /// /// Configures the to use the specified . /// /// The callback to use. + [Obsolete("Call ConfigureDataSource() and configure the client certificates on the NpgsqlDataSourceBuilder, or pass an externally-built, pre-configured NpgsqlDataSource to UseNpgsql().")] public virtual NpgsqlDbContextOptionsBuilder ProvideClientCertificatesCallback(ProvideClientCertificatesCallback? callback) => WithOption(e => e.WithProvideClientCertificatesCallback(callback)); @@ -144,6 +160,7 @@ public virtual NpgsqlDbContextOptionsBuilder ProvideClientCertificatesCallback(P /// Configures the to use the specified . /// /// The callback to use. + [Obsolete("Call ConfigureDataSource() and configure remote certificate validation on the NpgsqlDataSourceBuilder, or pass an externally-built, pre-configured NpgsqlDataSource to UseNpgsql().")] public virtual NpgsqlDbContextOptionsBuilder RemoteCertificateValidationCallback(RemoteCertificateValidationCallback? callback) => WithOption(e => e.WithRemoteCertificateValidationCallback(callback)); @@ -151,12 +168,11 @@ public virtual NpgsqlDbContextOptionsBuilder RemoteCertificateValidationCallback /// Configures the to use the specified . /// /// The callback to use. -#pragma warning disable CS0618 // ProvidePasswordCallback is obsolete + [Obsolete("Call ConfigureDataSource() and configure the password callback on the NpgsqlDataSourceBuilder, or pass an externally-built, pre-configured NpgsqlDataSource to UseNpgsql().")] public virtual NpgsqlDbContextOptionsBuilder ProvidePasswordCallback(ProvidePasswordCallback? callback) => WithOption(e => e.WithProvidePasswordCallback(callback)); -#pragma warning restore CS0618 - #endregion Authentication + #endregion Authentication (obsolete) #region Retrying execution strategy diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlDataSourceManager.cs b/src/EFCore.PG/Storage/Internal/NpgsqlDataSourceManager.cs index 9859e2cd0..852d86889 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlDataSourceManager.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlDataSourceManager.cs @@ -68,6 +68,7 @@ public NpgsqlDataSourceManager(IEnumerable { ConnectionString: null } or null => null, // The following are features which require an NpgsqlDataSource, since they require configuration on NpgsqlDataSourceBuilder. + { DataSourceBuilderAction: not null } => GetSingletonDataSource(npgsqlOptionsExtension), { EnumDefinitions.Count: > 0 } => GetSingletonDataSource(npgsqlOptionsExtension), _ when _plugins.Any() => GetSingletonDataSource(npgsqlOptionsExtension), @@ -139,6 +140,10 @@ enumDefinition.StoreTypeSchema is null dataSourceBuilder.UseUserCertificateValidationCallback(npgsqlOptionsExtension.RemoteCertificateValidationCallback); } + // Finally, if the user has provided a data source builder configuration action, invoke it. + // Do this last, to allow the user to override anything set above. + npgsqlOptionsExtension.DataSourceBuilderAction?.Invoke(dataSourceBuilder); + return dataSourceBuilder.Build(); } diff --git a/test/EFCore.PG.Tests/NpgsqlRelationalConnectionTest.cs b/test/EFCore.PG.Tests/NpgsqlRelationalConnectionTest.cs index 8b72df045..ceba20914 100644 --- a/test/EFCore.PG.Tests/NpgsqlRelationalConnectionTest.cs +++ b/test/EFCore.PG.Tests/NpgsqlRelationalConnectionTest.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.Storage.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; @@ -11,6 +12,8 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL; +#nullable enable + public class NpgsqlRelationalConnectionTest { [Fact] @@ -30,13 +33,14 @@ public void Uses_DbDataSource_from_DbContextOptions() serviceCollection .AddNpgsqlDataSource("Host=FakeHost") + // ReSharper disable once AccessToDisposedClosure .AddDbContext(o => o.UseNpgsql(dataSource)); using var serviceProvider = serviceCollection.BuildServiceProvider(); using var scope1 = serviceProvider.CreateScope(); var context1 = scope1.ServiceProvider.GetRequiredService(); - var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService()!; + var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Same(dataSource, relationalConnection1.DbDataSource); var connection1 = context1.GetService().Database.GetDbConnection(); @@ -44,7 +48,7 @@ public void Uses_DbDataSource_from_DbContextOptions() using var scope2 = serviceProvider.CreateScope(); var context2 = scope2.ServiceProvider.GetRequiredService(); - var relationalConnection2 = (NpgsqlRelationalConnection)context2.GetService()!; + var relationalConnection2 = (NpgsqlRelationalConnection)context2.GetService(); Assert.Same(dataSource, relationalConnection2.DbDataSource); var connection2 = context2.GetService().Database.GetDbConnection(); @@ -66,7 +70,7 @@ public void Uses_DbDataSource_from_application_service_provider() using var scope1 = serviceProvider.CreateScope(); var context1 = scope1.ServiceProvider.GetRequiredService(); - var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService()!; + var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Same(dataSource, relationalConnection1.DbDataSource); var connection1 = context1.GetService().Database.GetDbConnection(); @@ -74,7 +78,7 @@ public void Uses_DbDataSource_from_application_service_provider() using var scope2 = serviceProvider.CreateScope(); var context2 = scope2.ServiceProvider.GetRequiredService(); - var relationalConnection2 = (NpgsqlRelationalConnection)context2.GetService()!; + var relationalConnection2 = (NpgsqlRelationalConnection)context2.GetService(); Assert.Same(dataSource, relationalConnection2.DbDataSource); var connection2 = context2.GetService().Database.GetDbConnection(); @@ -94,7 +98,7 @@ public void DbDataSource_from_application_service_provider_does_not_used_if_conn using var scope1 = serviceProvider.CreateScope(); var context1 = scope1.ServiceProvider.GetRequiredService(); - var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService()!; + var relationalConnection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Null(relationalConnection1.DbDataSource); var connection1 = context1.GetService().Database.GetDbConnection(); @@ -102,80 +106,174 @@ public void DbDataSource_from_application_service_provider_does_not_used_if_conn } [Fact] - public void Multiple_connection_strings_with_plugin() + public void Data_source_config_with_same_connection_string() + { + // The connection string is the same, so the same data source gets resolved. + // This works well as long as ConfigureDataSource() has the same lambda. + var context1 = new ConfigurableContext( + "Host=FakeHost1", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App1")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1;Application Name=App1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext( + "Host=FakeHost1", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App1")); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost1;Application Name=App1", connection1.ConnectionString); + Assert.Same(connection1.DbDataSource, connection2.DbDataSource); + } + + [Fact] + public void Data_source_config_with_different_connection_strings() + { + // When different connection strings are used, different data sources are created internally. + var context1 = new ConfigurableContext( + "Host=FakeHost1", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App1")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1;Application Name=App1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext( + "Host=FakeHost2", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App2")); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost2;Application Name=App2", connection2.ConnectionString); + Assert.NotSame(connection1.DbDataSource, connection2.DbDataSource); + } + + [Fact] + public void Data_source_config_with_same_connection_string_and_different_lambda() { - var context1 = new ConnectionStringSwitchingContext("Host=FakeHost1", withNetTopologySuite: true); + // Bad case: same connection string but with a different data source config lambda. + // Same data source gets reused, and so the differing data source config gets ignored. + var context1 = new ConfigurableContext( + "Host=FakeHost1", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App1")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1;Application Name=App1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext( + "Host=FakeHost1", no => no.ConfigureDataSource(dsb => dsb.ConnectionStringBuilder.ApplicationName = "App2")); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + // Note the incorrect Application Name below, because the 1st data source was resolved based on the connection string only + Assert.Equal("Host=FakeHost1;Application Name=App1", connection2.ConnectionString); + Assert.Same(connection1.DbDataSource, connection2.DbDataSource); + } + + [Fact] + public void Plugin_config_with_same_connection_string() + { + // The connection string and plugin config are the same, so the same data source gets resolved. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.UseNetTopologySuite()); var connection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Equal("Host=FakeHost1", connection1.ConnectionString); Assert.NotNull(connection1.DbDataSource); - var context2 = new ConnectionStringSwitchingContext("Host=FakeHost1", withNetTopologySuite: true); + var context2 = new ConfigurableContext("Host=FakeHost1", no => no.UseNetTopologySuite()); var connection2 = (NpgsqlRelationalConnection)context2.GetService(); - Assert.Equal("Host=FakeHost1", connection2.ConnectionString); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); Assert.Same(connection1.DbDataSource, connection2.DbDataSource); + } - var context3 = new ConnectionStringSwitchingContext("Host=FakeHost2", withNetTopologySuite: true); - var connection3 = (NpgsqlRelationalConnection)context3.GetService(); - Assert.Equal("Host=FakeHost2", connection3.ConnectionString); - Assert.NotSame(connection1.DbDataSource, connection3.DbDataSource); + [Fact] + public void Plugin_config_with_different_connection_strings() + { + // When different connection strings are used, different data sources are created internally. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.UseNetTopologySuite()); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext("Host=FakeHost2", no => no.UseNetTopologySuite()); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost2", connection2.ConnectionString); + Assert.NotSame(connection1.DbDataSource, connection2.DbDataSource); } [Fact] - public void Multiple_connection_strings_with_enum() + public void Plugin_config_with_different_connection_strings_and_different_plugins() { - var context1 = new ConnectionStringSwitchingContext("Host=FakeHost1", withEnum: true); + // Since the plugin configuration is a singleton option, a different service provider gets resolved and we have different data + // sources. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.UseNetTopologySuite()); var connection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Equal("Host=FakeHost1", connection1.ConnectionString); Assert.NotNull(connection1.DbDataSource); - var context2 = new ConnectionStringSwitchingContext("Host=FakeHost1", withEnum: true); + var context2 = new ConfigurableContext("Host=FakeHost1", no => no.UseNodaTime()); var connection2 = (NpgsqlRelationalConnection)context2.GetService(); Assert.Equal("Host=FakeHost1", connection2.ConnectionString); + Assert.NotSame(connection1.DbDataSource, connection2.DbDataSource); + } + + [Fact] + public void Enum_config_with_same_connection_string() + { + // The connection string and plugin config are the same, so the same data source gets resolved. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.MapEnum("mood")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext("Host=FakeHost1", no => no.MapEnum("mood")); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); Assert.Same(connection1.DbDataSource, connection2.DbDataSource); + } - var context3 = new ConnectionStringSwitchingContext("Host=FakeHost2", withEnum: true); - var connection3 = (NpgsqlRelationalConnection)context3.GetService(); - Assert.Equal("Host=FakeHost2", connection3.ConnectionString); - Assert.NotSame(connection1.DbDataSource, connection3.DbDataSource); + [Fact] + public void Enum_config_with_different_connection_strings() + { + // When different connection strings are used, different data sources are created internally. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.MapEnum("mood")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext("Host=FakeHost2", no => no.MapEnum("mood")); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost2", connection2.ConnectionString); + Assert.NotSame(connection1.DbDataSource, connection2.DbDataSource); + } + + [Fact] + public void Enum_config_with_different_connection_strings_and_different_enums() + { + // Since the enum configuration is a singleton option, a different service provider gets resolved, and we have different data + // sources. + var context1 = new ConfigurableContext("Host=FakeHost1", no => no.MapEnum("mood")); + var connection1 = (NpgsqlRelationalConnection)context1.GetService(); + Assert.Equal("Host=FakeHost1", connection1.ConnectionString); + Assert.NotNull(connection1.DbDataSource); + + var context2 = new ConfigurableContext("Host=FakeHost1", _ => { /* no enums */}); + var connection2 = (NpgsqlRelationalConnection)context2.GetService(); + Assert.Equal("Host=FakeHost1", connection2.ConnectionString); + Assert.NotSame(connection1.DbDataSource, connection2.DbDataSource); } [Fact] public void Multiple_connection_strings_without_data_source_features() { - var context1 = new ConnectionStringSwitchingContext("Host=FakeHost1"); + var context1 = new ConfigurableContext("Host=FakeHost1"); var connection1 = (NpgsqlRelationalConnection)context1.GetService(); Assert.Equal("Host=FakeHost1", connection1.ConnectionString); Assert.Null(connection1.DbDataSource); - var context2 = new ConnectionStringSwitchingContext("Host=FakeHost1"); + var context2 = new ConfigurableContext("Host=FakeHost1"); var connection2 = (NpgsqlRelationalConnection)context2.GetService(); Assert.Equal("Host=FakeHost1", connection2.ConnectionString); Assert.Null(connection2.DbDataSource); - var context3 = new ConnectionStringSwitchingContext("Host=FakeHost2"); + var context3 = new ConfigurableContext("Host=FakeHost2"); var connection3 = (NpgsqlRelationalConnection)context3.GetService(); Assert.Equal("Host=FakeHost2", connection3.ConnectionString); Assert.Null(connection3.DbDataSource); } - private class ConnectionStringSwitchingContext(string connectionString, bool withNetTopologySuite = false, bool withEnum = false) - : DbContext + private class ConfigurableContext(string connectionString, Action? npgsqlOptionsAction = null) : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString, b => - { - if (withNetTopologySuite) - { - b.UseNetTopologySuite(); - } - - if (withEnum) - { - b.MapEnum("mood"); - } - }); - - private enum Mood { Happy, Sad } + => optionsBuilder.UseNpgsql(connectionString, npgsqlOptionsAction); } [Fact] @@ -259,7 +357,7 @@ public void CloneWith_with_connection_and_connection_string() Assert.Equal("Host=localhost;Database=DummyDatabase;Application Name=foo", clone.ConnectionString); } - public static NpgsqlRelationalConnection CreateConnection(DbContextOptions options = null, DbDataSource dataSource = null) + public static NpgsqlRelationalConnection CreateConnection(DbContextOptions? options = null, DbDataSource? dataSource = null) { options ??= new DbContextOptionsBuilder() .UseNpgsql(@"Host=localhost;Database=NpgsqlConnectionTest;Username=some_user;Password=some_password") @@ -308,8 +406,7 @@ public static NpgsqlRelationalConnection CreateConnection(DbContextOptions optio private const string ConnectionString = "Fake Connection String"; - private static IDbContextOptions CreateOptions( - RelationalOptionsExtension optionsExtension = null) + private static IDbContextOptions CreateOptions(RelationalOptionsExtension? optionsExtension = null) { var optionsBuilder = new DbContextOptionsBuilder(); @@ -332,4 +429,12 @@ public FakeDbContext(DbContextOptions options) { } } + + private enum Mood + { + // ReSharper disable once UnusedMember.Local + Happy, + // ReSharper disable once UnusedMember.Local + Sad + } }