diff --git a/src/EFCore.PG/Infrastructure/Internal/INpgsqlOptions.cs b/src/EFCore.PG/Infrastructure/Internal/INpgsqlOptions.cs index 1d2299ead..1184c23a0 100644 --- a/src/EFCore.PG/Infrastructure/Internal/INpgsqlOptions.cs +++ b/src/EFCore.PG/Infrastructure/Internal/INpgsqlOptions.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Infrastructure; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal @@ -6,7 +8,13 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal public interface INpgsqlOptions : ISingletonOptions { /// - /// Reflects the option set by . + /// The backend version to target. + /// + [CanBeNull] + Version PostgresVersion { get; } + + /// + /// True if reverse null ordering is enabled; otherwise, false. /// bool ReverseNullOrderingEnabled { get; } diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs index 52ce59361..0f7cc77b8 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs @@ -1,4 +1,5 @@ #region License + // The PostgreSQL License // // Copyright (C) 2016 The Npgsql Development Team @@ -19,8 +20,10 @@ // AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS // ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS // TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + #endregion +using System; using System.Collections.Generic; using System.Net.Security; using JetBrains.Annotations; @@ -33,16 +36,36 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal { public class NpgsqlOptionsExtension : RelationalOptionsExtension { - public string AdminDatabase { get; private set; } - public bool? ReverseNullOrdering { get; private set; } - public ProvideClientCertificatesCallback ProvideClientCertificatesCallback { get; private set; } - public RemoteCertificateValidationCallback RemoteCertificateValidationCallback { get; private set; } + readonly List _plugins; + public string AdminDatabase { get; private set; } + public bool ReverseNullOrdering { get; private set; } + + /// + /// The backend version to target. + /// + [CanBeNull] + public Version PostgresVersion { get; private set; } + + /// + /// The collection of database plugins. + /// + [NotNull] + [ItemNotNull] public IReadOnlyList Plugins => _plugins; - readonly List _plugins = new List(); + /// + /// The specified . + /// + [CanBeNull] + public ProvideClientCertificatesCallback ProvideClientCertificatesCallback { get; private set; } + + public RemoteCertificateValidationCallback RemoteCertificateValidationCallback { get; private set; } - public NpgsqlOptionsExtension() {} + /// + /// Initializes an instance of with the default settings. + /// + public NpgsqlOptionsExtension() => _plugins = new List(); // NB: When adding new options, make sure to update the copy ctor below. @@ -51,9 +74,10 @@ public NpgsqlOptionsExtension([NotNull] NpgsqlOptionsExtension copyFrom) { AdminDatabase = copyFrom.AdminDatabase; ReverseNullOrdering = copyFrom.ReverseNullOrdering; + PostgresVersion = copyFrom.PostgresVersion; ProvideClientCertificatesCallback = copyFrom.ProvideClientCertificatesCallback; RemoteCertificateValidationCallback = copyFrom.RemoteCertificateValidationCallback; - _plugins.AddRange(copyFrom._plugins); + _plugins = new List(copyFrom._plugins); } protected override RelationalOptionsExtension Clone() => new NpgsqlOptionsExtension(this); @@ -85,6 +109,28 @@ public virtual NpgsqlOptionsExtension WithAdminDatabase(string adminDatabase) return clone; } + /// + /// Returns a copy of the current instance with the specified PostgreSQL version. + /// + /// The backend version to target. + /// + /// A copy of the current instance with the specified PostgreSQL version. + /// + [NotNull] + public virtual NpgsqlOptionsExtension WithPostgresVersion([CanBeNull] Version postgresVersion) + { + var clone = (NpgsqlOptionsExtension)Clone(); + + clone.PostgresVersion = postgresVersion; + + return clone; + } + + /// + /// Returns a copy of the current instance configured with the specified value.. + /// + /// True to enable reverse null ordering; otherwise, false. + [NotNull] internal virtual NpgsqlOptionsExtension WithReverseNullOrdering(bool reverseNullOrdering) { var clone = (NpgsqlOptionsExtension)Clone(); diff --git a/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs b/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs index 66e8497da..b66520a4f 100644 --- a/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs +++ b/src/EFCore.PG/Infrastructure/NpgsqlDbContextOptionsBuilder.cs @@ -50,6 +50,13 @@ public virtual void UsePlugin(NpgsqlEntityFrameworkPlugin plugin) /// public virtual void UseAdminDatabase(string dbName) => WithOption(e => e.WithAdminDatabase(dbName)); + /// + /// Configures the backend version to target. + /// + /// The backend version to target. + public virtual void SetPostgresVersion([CanBeNull] Version postgresVersion) + => WithOption(e => e.WithPostgresVersion(postgresVersion)); + /// /// 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 diff --git a/src/EFCore.PG/Internal/NpgsqlOptions.cs b/src/EFCore.PG/Internal/NpgsqlOptions.cs index b036dc572..bd665c44c 100644 --- a/src/EFCore.PG/Internal/NpgsqlOptions.cs +++ b/src/EFCore.PG/Internal/NpgsqlOptions.cs @@ -10,11 +10,22 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Internal { public class NpgsqlOptions : INpgsqlOptions { + /// + public virtual Version PostgresVersion { get; private set; } + + /// + public virtual bool ReverseNullOrderingEnabled { get; private set; } + + /// + public virtual IReadOnlyList Plugins { get; private set; } + + /// public void Initialize(IDbContextOptions options) { var npgsqlOptions = options.FindExtension() ?? new NpgsqlOptionsExtension(); - ReverseNullOrderingEnabled = npgsqlOptions.ReverseNullOrdering ?? false; + PostgresVersion = npgsqlOptions.PostgresVersion; + ReverseNullOrderingEnabled = npgsqlOptions.ReverseNullOrdering; Plugins = npgsqlOptions.Plugins; } @@ -22,7 +33,7 @@ public void Validate(IDbContextOptions options) { var npgsqlOptions = options.FindExtension() ?? new NpgsqlOptionsExtension(); - if (ReverseNullOrderingEnabled != (npgsqlOptions.ReverseNullOrdering ?? false)) + if (ReverseNullOrderingEnabled != npgsqlOptions.ReverseNullOrdering) { throw new InvalidOperationException( CoreStrings.SingletonOptionChanged( @@ -30,9 +41,5 @@ public void Validate(IDbContextOptions options) nameof(DbContextOptionsBuilder.UseInternalServiceProvider))); } } - - public virtual bool ReverseNullOrderingEnabled { get; private set; } - - public virtual IReadOnlyList Plugins { get; private set; } } } diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs index 5935a105d..993861271 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs @@ -34,7 +34,6 @@ public class NpgsqlCompositeMethodCallTranslator : RelationalCompositeMethodCall { new NpgsqlArraySequenceEqualTranslator(), new NpgsqlConvertTranslator(), - new NpgsqlDateAddTranslator(), new NpgsqlStringSubstringTranslator(), new NpgsqlLikeTranslator(), new NpgsqlMathTranslator(), @@ -60,14 +59,27 @@ public NpgsqlCompositeMethodCallTranslator( [NotNull] INpgsqlOptions npgsqlOptions) : base(dependencies) { + var instanceTranslators = + new IMethodCallTranslator[] + { + new NpgsqlDateAddTranslator(npgsqlOptions.PostgresVersion) + }; + // ReSharper disable once DoNotCallOverridableMethodsInConstructor AddTranslators(_methodCallTranslators); + // ReSharper disable once DoNotCallOverridableMethodsInConstructor + AddTranslators(instanceTranslators); + foreach (var plugin in npgsqlOptions.Plugins) plugin.AddMethodCallTranslators(this); } - public new virtual void AddTranslators([NotNull] IEnumerable translators) + /// + /// Adds additional dispatches to the translators list. + /// + /// The translators. + public new virtual void AddTranslators([NotNull] [ItemNotNull] IEnumerable translators) => base.AddTranslators(translators); } } diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateAddTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateAddTranslator.cs index 4b0c9a43e..50321a4de 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateAddTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateAddTranslator.cs @@ -1,16 +1,52 @@ -using System; +#region License + +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +#endregion + +using System; using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators; using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal { + /// + /// Provides expression translation for addition methods. + /// public class NpgsqlDateAddTranslator : IMethodCallTranslator { - readonly Dictionary _methodInfoDatePartMapping = new Dictionary + /// + /// The minimum backend version supported by this translator. + /// + [NotNull] static readonly Version MinimumSupportedVersion = new Version(9, 4); + + /// + /// The mapping of supported method translations. + /// + [NotNull] static readonly Dictionary MethodInfoDatePartMapping = new Dictionary { { typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddYears), new[] { typeof(int) }), "years" }, { typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddMonths), new[] { typeof(int) }), "months" }, @@ -29,15 +65,25 @@ public class NpgsqlDateAddTranslator : IMethodCallTranslator }; /// - /// Translates the given method call expression. + /// The backend version to target. /// - /// The method call expression. - /// - /// A SQL expression representing the translated MethodCallExpression. - /// + [NotNull] readonly Version _postgresVersion; + + /// + /// Initializes a new instance of the class. + /// + /// The backend version to target. + public NpgsqlDateAddTranslator([CanBeNull] Version postgresVersion) + => _postgresVersion = postgresVersion ?? MinimumSupportedVersion; + + /// public virtual Expression Translate(MethodCallExpression methodCallExpression) { - if (!_methodInfoDatePartMapping.TryGetValue(methodCallExpression.Method, out var datePart)) + // This translation is only supported for PostgreSQL 9.4 or higher. + if (_postgresVersion < MinimumSupportedVersion) + return null; + + if (!MethodInfoDatePartMapping.TryGetValue(methodCallExpression.Method, out var datePart)) return null; // Ideally we would use Expression.Add() to have a simple plus-sign expression generated for us. @@ -45,23 +91,27 @@ public virtual Expression Translate(MethodCallExpression methodCallExpression) // We could use the provider-specific NpgsqlTimeSpan but it's not currently supported and is somewhat // ugly, so we just generate the expression ourselves with CustomBinaryExpression - var amountToAdd = methodCallExpression.Arguments.First(); + Expression instance = methodCallExpression.Object; + Expression amountToAdd = methodCallExpression.Arguments[0]; + + if (instance is null || amountToAdd is null) + return null; - if (amountToAdd is ConstantExpression constantExpression && - constantExpression.Type == typeof(double) && - ((double)constantExpression.Value >= int.MaxValue || (double)constantExpression.Value <= int.MinValue)) - { + if (amountToAdd is ConstantExpression amount && + amount.Type == typeof(double) && + ((double)amount.Value >= int.MaxValue || + (double)amount.Value <= int.MinValue)) return null; - } - return new CustomBinaryExpression( - methodCallExpression.Object, - new PgFunctionExpression("MAKE_INTERVAL", typeof(TimeSpan), new Dictionary - { - { datePart, amountToAdd } - }), - "+", - methodCallExpression.Type); + return + new CustomBinaryExpression( + instance, + new PgFunctionExpression( + "MAKE_INTERVAL", + typeof(TimeSpan), + new Dictionary { [datePart] = amountToAdd }), + "+", + methodCallExpression.Type); } } } diff --git a/test/EFCore.PG.FunctionalTests/Query/CompatibilityQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/CompatibilityQueryNpgsqlTest.cs new file mode 100644 index 000000000..4f5af11a5 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/CompatibilityQueryNpgsqlTest.cs @@ -0,0 +1,217 @@ +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; +using Xunit; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query +{ + public class CompatibilityQueryNpgsqlTest : IClassFixture + { + /// + /// Provides resources for unit tests. + /// + CompatibilityQueryNpgsqlFixure Fixture { get; } + + /// + /// Initializes resources for unit tests. + /// + /// The fixture of resources for testing. + public CompatibilityQueryNpgsqlTest(CompatibilityQueryNpgsqlFixure fixture) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + } + + #region Tests + + [Theory] + [InlineData("9.4")] + [InlineData("9.5")] + [InlineData("9.6")] + [InlineData("10.0")] + [InlineData("10.1")] + [InlineData("10.2")] + [InlineData("10.3")] + [InlineData("10.4")] + public void GivenDateTimeAdd_WhenVersionIsSupported_ThenTranslates(string version) + { + using (CompatibilityContext context = Fixture.CreateContext(postgresVersion: Version.Parse(version))) + { + // ReSharper disable once ConvertToConstant.Local + int years = 2; + + DateTime[] _ = + context.CompatibilityTestEntities + .Select(x => x.DateTime.AddYears(years)) + .ToArray(); + + AssertContainsSql("SELECT (x.\"DateTime\" + MAKE_INTERVAL(years => @__years_0))"); + } + } + + [Theory] + [InlineData("9.0")] + [InlineData("9.1")] + [InlineData("9.2")] + [InlineData("9.3")] + public void GivenDateTimeAdd_WhenVersionIsNotSupported_ThenDoesNotTranslate(string version) + { + using (CompatibilityContext context = Fixture.CreateContext(postgresVersion: Version.Parse(version))) + { + // ReSharper disable once ConvertToConstant.Local + int years = 2; + + DateTime[] _ = + context.CompatibilityTestEntities + .Select(x => x.DateTime.AddYears(years)) + .ToArray(); + + AssertDoesNotContainsSql("SELECT (x.\"DateTime\" + MAKE_INTERVAL(years => @__years_0))"); + } + } + + #endregion + + #region Fixtures + + /// + /// Represents a fixture suitable for testing backendVersion. + /// + public class CompatibilityQueryNpgsqlFixure : IDisposable + { + /// + /// The used for testing. + /// + private readonly NpgsqlTestStore _testStore; + + /// + /// The logger factory used for testing. + /// + public TestSqlLoggerFactory TestSqlLoggerFactory { get; } + + /// + /// Initializes a . + /// + // ReSharper disable once UnusedMember.Global + public CompatibilityQueryNpgsqlFixure() + { + TestSqlLoggerFactory = new TestSqlLoggerFactory(); + + _testStore = NpgsqlTestStore.CreateScratch(); + + using (CompatibilityContext context = CreateContext()) + { + context.Database.EnsureCreated(); + + context.CompatibilityTestEntities.AddRange( + new CompatibilityTestEntity + { + Id = 1, + DateTime = new DateTime(2018, 06, 23) + }, + new CompatibilityTestEntity + { + Id = 2, + DateTime = new DateTime(2018, 06, 23) + }, + new CompatibilityTestEntity + { + Id = 3, + DateTime = new DateTime(2018, 06, 23) + }); + + context.SaveChanges(); + } + } + + /// + public void Dispose() => _testStore.Dispose(); + + /// + /// Creates a new . + /// + /// The backend version to target. + /// + /// A for testing. + /// + public CompatibilityContext CreateContext(Version postgresVersion = null) + => new CompatibilityContext( + new DbContextOptionsBuilder() + .UseNpgsql( + _testStore.ConnectionString, + x => + { + x.ApplyConfiguration(); + if (postgresVersion != null) + x.SetPostgresVersion(postgresVersion); + }) + .UseInternalServiceProvider( + new ServiceCollection() + .AddEntityFrameworkNpgsql() + .AddSingleton(TestSqlLoggerFactory) + .BuildServiceProvider()) + .Options); + } + + /// + /// Represents an entity suitable for testing backendVersion. + /// + public class CompatibilityTestEntity + { + /// + /// The primary key. + /// + public int Id { get; set; } + + /// + /// The date and time. + /// + public DateTime DateTime { get; set; } + } + + /// + /// Represents a database suitable for testing range operators. + /// + public class CompatibilityContext : DbContext + { + /// + /// Represents a set of entities for backendVersion testing. + /// + public DbSet CompatibilityTestEntities { get; set; } + + /// + public CompatibilityContext(DbContextOptions options) : base(options) {} + + /// + protected override void OnModelCreating(ModelBuilder builder) {} + } + + #endregion + + #region Helpers + + /// + /// Asserts that the SQL fragment appears in the logs. + /// + /// The SQL statement or fragment to search for in the logs. + public void AssertContainsSql(string sql) + { + Assert.Contains(sql, Fixture.TestSqlLoggerFactory.Sql); + } + + /// + /// Asserts that the SQL fragment does not appears in the logs. + /// + /// The SQL statement or fragment to search for in the logs. + public void AssertDoesNotContainsSql(string sql) + { + Assert.DoesNotContain(sql, Fixture.TestSqlLoggerFactory.Sql); + } + + #endregion + } +}