diff --git a/Directory.Packages.props b/Directory.Packages.props index 37851bc2..6e583e05 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + diff --git a/src/Dapper.AOT/Internal/CommandUtils.cs b/src/Dapper.AOT/Internal/CommandUtils.cs index a03a9fa1..00ac9414 100644 --- a/src/Dapper.AOT/Internal/CommandUtils.cs +++ b/src/Dapper.AOT/Internal/CommandUtils.cs @@ -165,6 +165,46 @@ internal static T As(object? value) DateTime? t = Convert.ToDateTime(value, CultureInfo.InvariantCulture); return Unsafe.As(ref t); } +#if NET6_0_OR_GREATER + else if (typeof(T) == typeof(DateOnly)) + { + if (value is DateOnly only) return Unsafe.As(ref only); + + DateTime t = Convert.ToDateTime(value, CultureInfo.InvariantCulture); + var dateOnly = DateOnly.FromDateTime(t); + return Unsafe.As(ref dateOnly); + } + else if (typeof(T) == typeof(DateOnly?)) + { + DateTime? t = Convert.ToDateTime(value, CultureInfo.InvariantCulture); + DateOnly? dateOnly = t is null ? null : DateOnly.FromDateTime(t.Value); + return Unsafe.As(ref dateOnly); + } + else if (typeof(T) == typeof(TimeOnly)) + { + if (value is TimeSpan timeSpan) + { + var fromSpan = TimeOnly.FromTimeSpan(timeSpan); + return Unsafe.As(ref fromSpan); + } + + DateTime t = Convert.ToDateTime(value, CultureInfo.InvariantCulture); + var timeOnly = TimeOnly.FromDateTime(t); + return Unsafe.As(ref timeOnly); + } + else if (typeof(T) == typeof(TimeOnly?)) + { + if (value is TimeSpan timeSpan) + { + var fromSpan = TimeOnly.FromTimeSpan(timeSpan); + return Unsafe.As(ref fromSpan); + } + + DateTime? t = Convert.ToDateTime(value, CultureInfo.InvariantCulture); + TimeOnly? timeOnly = t is null ? null : TimeOnly.FromDateTime(t.Value); + return Unsafe.As(ref timeOnly); + } +#endif else if (typeof(T) == typeof(Guid) && (s = value as string) is not null) { Guid t = Guid.Parse(s); diff --git a/src/Dapper.AOT/Properties/AssemblyInfo.cs b/src/Dapper.AOT/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..62522625 --- /dev/null +++ b/src/Dapper.AOT/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dapper.AOT.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a17ba361da0990b3da23f3c20f2a002242397b452a28f27832d61d49f35edb54a68b98d98557b8a02be79be42142339c7861af309c8917dee972775e2c358dd6b96109a9147987652b25b8dc52e7f61f22a755831674f0a3cea17bef9abb6b23ef1856a02216864a1ffbb04a4c549258d32ba740fe141dad2f298a8130ea56d0")] \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration.Executables/Dapper.AOT.Test.Integration.Executables.csproj b/test/Dapper.AOT.Test.Integration.Executables/Dapper.AOT.Test.Integration.Executables.csproj index 828f4929..b996bea0 100644 --- a/test/Dapper.AOT.Test.Integration.Executables/Dapper.AOT.Test.Integration.Executables.csproj +++ b/test/Dapper.AOT.Test.Integration.Executables/Dapper.AOT.Test.Integration.Executables.csproj @@ -1,6 +1,6 @@  - net8.0;net6.0;net48 + net8.0;net6.0 Dapper.AOT.Test.Integration.Executables diff --git a/test/Dapper.AOT.Test.Integration.Executables/Models/DateOnlyTimeOnlyPoco.cs b/test/Dapper.AOT.Test.Integration.Executables/Models/DateOnlyTimeOnlyPoco.cs new file mode 100644 index 00000000..a7e3e683 --- /dev/null +++ b/test/Dapper.AOT.Test.Integration.Executables/Models/DateOnlyTimeOnlyPoco.cs @@ -0,0 +1,15 @@ +using System; + +namespace Dapper.AOT.Test.Integration.Executables.Models; + +public class DateOnlyTimeOnlyPoco +{ + public const string TableName = "dateOnlyTimeOnly"; + + public static DateOnly SpecificDate => DateOnly.FromDateTime(new DateTime(year: 2022, month: 2, day: 2)); + public static TimeOnly SpecificTime => TimeOnly.FromDateTime(new DateTime(year: 2022, month: 1, day: 1, hour: 10, minute: 11, second: 12)); + + public int Id { get; set; } + public DateOnly Date { get; set; } + public TimeOnly Time { get; set; } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyInsert.cs b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyInsert.cs new file mode 100644 index 00000000..6e530980 --- /dev/null +++ b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyInsert.cs @@ -0,0 +1,30 @@ +using System.Data; +using System.Linq; +using Dapper.AOT.Test.Integration.Executables.Models; + +namespace Dapper.AOT.Test.Integration.Executables.UserCode.DateOnlyTimeOnly; + +[DapperAot] +public class DateOnlyInsert : IExecutable +{ + public DateOnlyTimeOnlyPoco Execute(IDbConnection connection) + { + connection.Execute( + $""" + insert into {DateOnlyTimeOnlyPoco.TableName}(id, date, time) + values (@id, @date, @time) + on conflict (id) do nothing; + """, + new DateOnlyTimeOnlyPoco { Id = 2, Date = DateOnlyTimeOnlyPoco.SpecificDate, Time = DateOnlyTimeOnlyPoco.SpecificTime } + ); + + var results = connection.Query( + $""" + select * from {DateOnlyTimeOnlyPoco.TableName} + where id = 2 + """ + ); + + return results.First(); + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyTimeOnlyUsage.cs b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyTimeOnlyUsage.cs new file mode 100644 index 00000000..2ab1685b --- /dev/null +++ b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyTimeOnlyUsage.cs @@ -0,0 +1,16 @@ +using System; +using System.Data; +using System.Linq; +using Dapper.AOT.Test.Integration.Executables.Models; + +namespace Dapper.AOT.Test.Integration.Executables.UserCode.DateOnlyTimeOnly; + +[DapperAot] +public class DateOnlyTimeOnlyUsage : IExecutable +{ + public DateOnlyTimeOnlyPoco Execute(IDbConnection connection) + { + var results = connection.Query($"select * from {DateOnlyTimeOnlyPoco.TableName}"); + return results.First(); + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithDateFilter.cs b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithDateFilter.cs new file mode 100644 index 00000000..036096ba --- /dev/null +++ b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithDateFilter.cs @@ -0,0 +1,23 @@ +using System; +using System.Data; +using System.Linq; +using Dapper.AOT.Test.Integration.Executables.Models; + +namespace Dapper.AOT.Test.Integration.Executables.UserCode.DateOnlyTimeOnly; + +[DapperAot] +public class DateOnlyUsageWithDateFilter : IExecutable +{ + public DateOnlyTimeOnlyPoco Execute(IDbConnection connection) + { + var results = connection.Query( + $""" + select * from {DateOnlyTimeOnlyPoco.TableName} + where date = @date + """, + new { date = DateOnlyTimeOnlyPoco.SpecificDate } + ); + + return results.First(); + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithTimeFilter.cs b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithTimeFilter.cs new file mode 100644 index 00000000..ffdf5f26 --- /dev/null +++ b/test/Dapper.AOT.Test.Integration.Executables/UserCode/DateOnlyTimeOnly/DateOnlyUsageWithTimeFilter.cs @@ -0,0 +1,23 @@ +using System; +using System.Data; +using System.Linq; +using Dapper.AOT.Test.Integration.Executables.Models; + +namespace Dapper.AOT.Test.Integration.Executables.UserCode.DateOnlyTimeOnly; + +[DapperAot] +public class DateOnlyUsageWithTimeFilter : IExecutable +{ + public DateOnlyTimeOnlyPoco Execute(IDbConnection connection) + { + var results = connection.Query( + $""" + select * from {DateOnlyTimeOnlyPoco.TableName} + where time = @time + """, + new { time = DateOnlyTimeOnlyPoco.SpecificTime } + ); + + return results.First(); + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration/DateOnlyTimeOnlyTests.cs b/test/Dapper.AOT.Test.Integration/DateOnlyTimeOnlyTests.cs new file mode 100644 index 00000000..ba48aa73 --- /dev/null +++ b/test/Dapper.AOT.Test.Integration/DateOnlyTimeOnlyTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Data; +using Dapper.AOT.Test.Integration.Executables.Models; +using Dapper.AOT.Test.Integration.Executables.UserCode.DateOnlyTimeOnly; +using Dapper.AOT.Test.Integration.Setup; + +namespace Dapper.AOT.Test.Integration; + +[Collection(SharedPostgresqlClient.Collection)] +public class DateOnlyTimeOnlyTests : IntegrationTestsBase +{ + public DateOnlyTimeOnlyTests(PostgresqlFixture fixture) : base(fixture) + { + } + + protected override void SetupDatabase(IDbConnection dbConnection) + { + base.SetupDatabase(dbConnection); + + dbConnection.Execute($""" + CREATE TABLE IF NOT EXISTS {DateOnlyTimeOnlyPoco.TableName}( + id integer PRIMARY KEY, + date DATE, + time TIME + ); + + TRUNCATE {DateOnlyTimeOnlyPoco.TableName}; + + INSERT INTO {DateOnlyTimeOnlyPoco.TableName} (id, date, time) + VALUES (1, '{DateOnlyTimeOnlyPoco.SpecificDate.ToString("yyyy-MM-dd")}', '{DateOnlyTimeOnlyPoco.SpecificTime.ToString("HH:mm:ss")}') + """); + } + + [Fact] + public void DateOnly_BasicUsage_InterceptsAndReturnsExpectedData() + { + var result = ExecuteInterceptedUserCode(DbConnection); + Assert.Equal(1, result.Id); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificDate, result.Date); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificTime, result.Time); + } + + [Fact] + public void DateOnly_WithDateFilter_InterceptsAndReturnsExpectedData() + { + var result = ExecuteInterceptedUserCode(DbConnection); + Assert.Equal(1, result.Id); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificDate, result.Date); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificTime, result.Time); + } + + [Fact] + public void DateOnly_WithTimeFilter_InterceptsAndReturnsExpectedData() + { + var result = ExecuteInterceptedUserCode(DbConnection); + Assert.Equal(1, result.Id); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificDate, result.Date); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificTime, result.Time); + } + + [Fact] + public void DateOnly_Inserts_InterceptsAndReturnsExpectedData() + { + var result = ExecuteInterceptedUserCode(DbConnection); + Assert.Equal(2, result.Id); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificDate, result.Date); + Assert.Equal(DateOnlyTimeOnlyPoco.SpecificTime, result.Time); + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration/README.md b/test/Dapper.AOT.Test.Integration/README.md new file mode 100644 index 00000000..b3314245 --- /dev/null +++ b/test/Dapper.AOT.Test.Integration/README.md @@ -0,0 +1,29 @@ +# Dapper.AOT.Test - Integration + +This is a project for integration tests against a non-mocked database using generated interceptor bits. + +### Requirements +Make sure you have Docker Desktop running to be able to initialize a container with PostgreSQL db (or other). + +### How to add a new test +1) Add your tests to `Dapper.AOT.Test.Integration` and inherit the [IntegrationTestBase](./Setup/IntegrationTestsBase.cs) class +2) Override `SetupDatabase` with in example creating a table and filling it in with some data to query later +3) Call `IntegrationTestBase.ExecuteInterceptedUserCode()` where: + - `TUsage` is a type specified in [Dapper.AOT.Test.Integration.Executables/UserCode](../Dapper.AOT.Test.Integration.Executables/UserCode) project + and implements [IExecutable](../Dapper.AOT.Test.Integration.Executables/IExecutable.cs) interface. + The method `IExecutable.Execute(DbConnection connection)` will be executed by the framework automatically + - `TPoco` is the return type of `IExecutable.Execute(DbConnection connection)`. `TPoco` has to be also defined in [Dapper.AOT.Test.Integration.Executables/Models](../Dapper.AOT.Test.Integration.Executables/Models) +4) Assert that returned `TPoco` has all the data you expected + +### How test framework works + +- Create compilation with reference to user code ([Dapper.AOT.Test.Integration.Executables](../Dapper.AOT.Test.Integration.Executables)) via `CSharpCompilation.Create()` +- give it to `ISourceGenerator` and get an output compilation +- call `outputCompilation.Emit()` and get an assembly +- create instance of user code via reflection (Activator) - this is where we need the `IExecutable` interface to properly cast the type to it +- call `IExecutable.Execute(DbConnection connection)` passing the connection to container-db +- get the output `object`, cast it back to `TPoco` (also defined in [Dapper.AOT.Test.Integration.Executables](../Dapper.AOT.Test.Integration.Executables)) and return it to the test. + Developer can write assertions based on the returned object + +_note_: SourceGenerator is specifically created with the injected `IInterceptorRecorder`, which gets the stack trace of execution and verifies that generated bits were executed. +This gives a full confidence that Interceptors worked as expected. Also it reports diagnostics if something unexpected occured, and test verifies there are none of those. \ No newline at end of file diff --git a/test/Dapper.AOT.Test.Integration/Setup/IntegrationTestsBase.cs b/test/Dapper.AOT.Test.Integration/Setup/IntegrationTestsBase.cs index 762259de..3ea3ec41 100644 --- a/test/Dapper.AOT.Test.Integration/Setup/IntegrationTestsBase.cs +++ b/test/Dapper.AOT.Test.Integration/Setup/IntegrationTestsBase.cs @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; +using Microsoft.Data.SqlClient; namespace Dapper.AOT.Test.Integration.Setup; @@ -61,6 +62,7 @@ protected TResult ExecuteInterceptedUserCode(IDbConnection // Additional stuff required by Dapper.AOT generators MetadataReference.CreateFromFile(Assembly.Load("System.Collections").Location), // System.Collections + MetadataReference.CreateFromFile(Assembly.Load("System.Collections.Immutable").Location), // System.Collections.Immutable ], options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); @@ -77,8 +79,8 @@ protected TResult ExecuteInterceptedUserCode(IDbConnection var mainMethod = type.GetMethod(nameof(IExecutable.Execute), BindingFlags.Public | BindingFlags.Instance); var result = mainMethod!.Invoke(obj: executableInstance, [ dbConnection ]); - Assert.True(interceptorRecorder.WasCalled); - Assert.True(string.IsNullOrEmpty(interceptorRecorder.Diagnostics), userMessage: interceptorRecorder.Diagnostics); + Assert.True(interceptorRecorder.WasCalled, userMessage: "No interception code invoked"); + Assert.True(string.IsNullOrEmpty(interceptorRecorder.Diagnostics), userMessage: $"Expected no diagnostics from interceptorRecorder. Actual: {interceptorRecorder.Diagnostics}"); return (TResult)result!; } @@ -96,6 +98,14 @@ private static string ReadUserSourceCode() // it's very fragile to get user code cs files into output directory (btw we can't remove them from compilation, because we will use them for assertions) // so let's simply get back to test\ dir, and try to find Executables.UserCode from there var testDir = Directory.GetParent(Directory.GetParent(Directory.GetParent(Directory.GetParent(Directory.GetCurrentDirectory())!.FullName)!.FullName)!.FullName); - return File.ReadAllText(Path.Combine(testDir!.FullName, "Dapper.AOT.Test.Integration.Executables", "UserCode", $"{userTypeName}.cs")); + + var sourceCodeFile = Directory + .GetFiles( + path: Path.Combine(testDir!.FullName, "Dapper.AOT.Test.Integration.Executables", "UserCode"), + searchPattern: $"{userTypeName}.cs", + searchOption: SearchOption.AllDirectories) + .First(); + + return File.ReadAllText(sourceCodeFile); } } \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj b/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj index 663a4053..f447d516 100644 --- a/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj +++ b/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj @@ -56,6 +56,7 @@ + diff --git a/test/Dapper.AOT.Test/Helpers/TestFramework.cs b/test/Dapper.AOT.Test/Helpers/TestFramework.cs new file mode 100644 index 00000000..107af8a1 --- /dev/null +++ b/test/Dapper.AOT.Test/Helpers/TestFramework.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Dapper.AOT.Test.Helpers +{ + internal static class TestFramework + { + public static readonly ISet NetVersions + = ((Net[])Enum.GetValues(typeof(Net))) + .Select(static x => x.ToString()) + .ToHashSet(); + + public static Net DetermineNetVersion() + { +#if NET6_0_OR_GREATER + return Net.Net6; +#endif + return Net.Net48; + } + + public enum Net + { + Net48, + Net6 + } + } +} diff --git a/test/Dapper.AOT.Test/Integration/DateOnlyTimeOnlyTests.cs b/test/Dapper.AOT.Test/Integration/DateOnlyTimeOnlyTests.cs new file mode 100644 index 00000000..5aa87d6e --- /dev/null +++ b/test/Dapper.AOT.Test/Integration/DateOnlyTimeOnlyTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Data; +using System.Data.Common; +using Dapper.Internal; +using Xunit; + +namespace Dapper.AOT.Test.Integration; + +[Collection(SharedPostgresqlClient.Collection)] +public class DateOnlyTimeOnlyPostgreSqlTests : DateOnlyTimeOnlyTests +{ + public DateOnlyTimeOnlyPostgreSqlTests(PostgresqlFixture postgresqlFixture) : base( + postgresqlFixture.NpgsqlConnection, + $""" + CREATE TABLE IF NOT EXISTS date_only_table( + type_timestamp timestamp, + type_date date, + type_time time + ); + TRUNCATE date_only_table; + + INSERT INTO date_only_table (type_timestamp, type_date, type_time) + VALUES ('{TimeStampConst.ToString("yyyy-MM-dd HH:mm:ss")}', '2021-01-01', '03:03:03'); + """) + { + } +} + +[Collection(SharedMsSqlClient.Collection)] +public class DateOnlyTimeOnlyMsSqlTests : DateOnlyTimeOnlyTests +{ + public DateOnlyTimeOnlyMsSqlTests(MsSqlFixture msSqlFixture) : base( + msSqlFixture.MsSqlConnection, + $""" + IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='date_only_table' and xtype='U') + BEGIN + CREATE TABLE date_only_table( + type_timestamp datetime, + type_date date, + type_time time + ); + END; + TRUNCATE TABLE date_only_table; + + INSERT INTO date_only_table (type_timestamp, type_date, type_time) + VALUES ('2020-02-02 02:02:02', '2021-01-01', '03:03:03'); + """) + { + } +} + +public abstract class DateOnlyTimeOnlyTests +{ + protected static DateTime TimeStampConst = new DateTime(year: 2020, month: 2, day: 2, hour: 2, minute: 2, second: 2, kind: DateTimeKind.Utc); + +#if NET6_0_OR_GREATER + static DateOnly DateOnlyConst = new DateOnly(year: 2021, month: 1, day: 1); + static TimeOnly TimeOnlyConst = new TimeOnly(hour: 3, minute: 3, second: 3); +#endif + + readonly IDbConnection _dbConnection; + + public DateOnlyTimeOnlyTests(IDbConnection dbConnection, string initSql) + { + _dbConnection = dbConnection; + _dbConnection.Execute(initSql); + } + + [Fact] + public void ReadTimestamp() + { + using var cmd = _dbConnection.CreateCommand(); + cmd.CommandText = "select type_timestamp from date_only_table"; + using var reader = cmd.ExecuteReader(); + + var readResult = reader.Read(); + Assert.True(readResult); + + // DbReader will return `DateTime` here + var value = reader.GetValue(0); + + var dateTime = CommandUtils.As(value); + Assert.Equal(TimeStampConst, dateTime); + +#if NET6_0_OR_GREATER + // DateTime has Date and Time, so it can be cast to DateOnly and TimeOnly + var dateOnly = CommandUtils.As(value); + Assert.Equal(DateOnly.FromDateTime(TimeStampConst), dateOnly); + + var timeOnly = CommandUtils.As(value); + Assert.Equal(TimeOnly.FromDateTime(TimeStampConst), timeOnly); +#endif + } + + [Fact] + public void ReadDateOnly() + { + using var cmd = _dbConnection.CreateCommand(); + cmd.CommandText = "select type_date from date_only_table"; + using var reader = cmd.ExecuteReader(); + + var readResult = reader.Read(); + Assert.True(readResult); + + // DbReader will return `DateTime` here + var value = reader.GetValue(0); + + // technically, date can be interpreted as DateTime. + // so lets ensure it is possible to cast to DateTime with default time, but existing year-month-day + var dateTime = CommandUtils.As(value); + Assert.Equal(new DateTime(year: 2021, month: 1, day: 1), dateTime); + +#if NET6_0_OR_GREATER + var dateOnly = CommandUtils.As(value); + Assert.Equal(DateOnlyConst, dateOnly); + + // it is possible to cast, but it returns default timeOnly, because there is no "time" in actual data + // not sure if we need to explicitly throw here to show it's not actual time + var timeOnly = CommandUtils.As(value); + Assert.Equal(default(TimeOnly), timeOnly); +#endif + } + + [Fact] + public void ReadTimeOnly() + { + using var cmd = _dbConnection.CreateCommand(); + cmd.CommandText = "select type_time from date_only_table"; + using var reader = cmd.ExecuteReader(); + + var readResult = reader.Read(); + Assert.True(readResult); + + // DbReader will return `TimeSpan` here + var value = reader.GetValue(0); + + // TimeSpan is not a DateTime, so lets ensure it throws on casting to DateTime + Assert.Throws(() => CommandUtils.As(value)); + +#if NET6_0_OR_GREATER + // we cant cast TimeSpan to DateOnly - therefore it's expected to throw + Assert.Throws(() => CommandUtils.As(value)); + + var timeOnly = CommandUtils.As(value); + Assert.Equal(TimeOnlyConst, timeOnly); +#endif + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Integration/MsSqlFixture.cs b/test/Dapper.AOT.Test/Integration/MsSqlFixture.cs new file mode 100644 index 00000000..ac318a5f --- /dev/null +++ b/test/Dapper.AOT.Test/Integration/MsSqlFixture.cs @@ -0,0 +1,55 @@ +using Microsoft.Data.SqlClient; +using System.Data; +using System.Threading.Tasks; +using Testcontainers.MsSql; +using Xunit; + +namespace Dapper.AOT.Test.Integration; + +[CollectionDefinition(Collection)] +public class SharedMsSqlClient : ICollectionFixture +{ + public const string Collection = nameof(SharedMsSqlClient); +} + +public sealed class MsSqlFixture : IAsyncLifetime +{ + private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder() + .Build(); + + public string ConnectionString { get; private set; } = null!; + + private SqlConnection? _sharedConnection; + + public SqlConnection MsSqlConnection + => _sharedConnection ??= CreateOpenConnection(); + + public SqlConnection CreateOpenConnection() + { + var conn = new SqlConnection(ConnectionString); + conn.Open(); + return conn; + } + + async Task IAsyncLifetime.InitializeAsync() + { + await _msSqlContainer.StartAsync(); + ConnectionString = _msSqlContainer.GetConnectionString(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await using (_msSqlContainer) + { + var tmp = _sharedConnection; + _sharedConnection = null; + if (tmp is not null) + { + tmp.Close(); +#if NET6_0_OR_GREATER + await tmp.DisposeAsync(); +#endif + } + } + } +} diff --git a/test/Dapper.AOT.Test/InterceptorTests.cs b/test/Dapper.AOT.Test/InterceptorTests.cs index 79bfe4e6..2f863dad 100644 --- a/test/Dapper.AOT.Test/InterceptorTests.cs +++ b/test/Dapper.AOT.Test/InterceptorTests.cs @@ -1,4 +1,5 @@ -using Dapper.CodeAnalysis; +using Dapper.AOT.Test.Helpers; +using Dapper.CodeAnalysis; using System; using System.Collections.Generic; using System.IO; @@ -14,10 +15,33 @@ public class InterceptorTests : GeneratorTestBase { public InterceptorTests(ITestOutputHelper log) : base(log) { } - public static IEnumerable GetFiles() => - from path in Directory.GetFiles("Interceptors", "*.cs", SearchOption.AllDirectories) - where path.EndsWith(".input.cs", StringComparison.OrdinalIgnoreCase) - select new object[] { path }; + public static IEnumerable GetFiles() + { + var currentNetVersion = TestFramework.DetermineNetVersion(); + + foreach (var path in Directory.GetFiles("Interceptors", "*.cs", SearchOption.AllDirectories)) + { + if (path.EndsWith(".input.cs", StringComparison.OrdinalIgnoreCase)) + { + var fileName = Path.GetFileName(path); + var fileNetVersionStr = fileName.Split('.')[1]; + if (TestFramework.NetVersions.Contains(fileNetVersionStr)) + { + // it has to be the same or greater version + var fileNetVersion = (TestFramework.Net)Enum.Parse(typeof(TestFramework.Net), fileNetVersionStr); + if (currentNetVersion < fileNetVersion) + { + // skip if current version is lower than specified in the input file name + continue; + } + } + + yield return new object[] { path }; + } + } + + yield break; + } [Theory, MemberData(nameof(GetFiles))] public void Test(string path) diff --git a/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.input.cs b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.input.cs new file mode 100644 index 00000000..2e628cb0 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.input.cs @@ -0,0 +1,34 @@ +using System; +using Dapper; +using System.Data.Common; +using System.Threading.Tasks; + +[module: DapperAot] + +public static class Foo +{ + static async Task SomeCode(DbConnection connection) + { + _ = await connection.QueryAsync("select * from users where birth_date = @BirthDate", new + { + BirthDate = DateOnly.FromDateTime(DateTime.Now) + }); + + _ = await connection.QueryAsync("select * from users where birth_date = @BirthDate", new QueryModel + { + BirthDate = DateOnly.FromDateTime(DateTime.Now) + }); + } + + public class QueryModel + { + public DateOnly BirthDate { get; set; } + } + + public class User + { + public int Id { get; set; } + public string Name { get; set; } + public DateOnly BirthDate { get; set; } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.cs b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.cs new file mode 100644 index 00000000..58f943b4 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.cs @@ -0,0 +1,190 @@ +#nullable enable +namespace Dapper.AOT // interceptors must be in a known namespace +{ + file static class DapperGeneratedInterceptors + { + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\DateOnly.net6.input.cs", 12, 30)] + internal static global::System.Threading.Tasks.Task> QueryAsync0(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, Async, TypedResult, HasParameters, Buffered, Text, BindResultsByName, KnownParameters + // takes parameter: + // parameter map: BirthDate + // returns data: global::Foo.User + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.AsEnumerableAsync( + global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBufferedAsync(param, RowFactory0.Instance)); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\DateOnly.net6.input.cs", 17, 30)] + internal static global::System.Threading.Tasks.Task> QueryAsync1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, Async, TypedResult, HasParameters, Buffered, Text, BindResultsByName, KnownParameters + // takes parameter: global::Foo.QueryModel + // parameter map: BirthDate + // returns data: global::Foo.User + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.AsEnumerableAsync( + global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory1.Instance).QueryBufferedAsync((global::Foo.QueryModel)param!, RowFactory0.Instance)); + + } + + private class CommonCommandFactory : global::Dapper.CommandFactory + { + public override global::System.Data.Common.DbCommand GetCommand(global::System.Data.Common.DbConnection connection, string sql, global::System.Data.CommandType commandType, T args) + { + var cmd = base.GetCommand(connection, sql, commandType, args); + // apply special per-provider command initialization logic for OracleCommand + if (cmd is global::Oracle.ManagedDataAccess.Client.OracleCommand cmd0) + { + cmd0.BindByName = true; + cmd0.InitialLONGFetchSize = -1; + + } + return cmd; + } + + } + + private static readonly CommonCommandFactory DefaultCommandFactory = new(); + + private sealed class RowFactory0 : global::Dapper.RowFactory + { + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 926444256U when NormalizedEquals(name, "id"): + token = type == typeof(int) ? 0 : 3; // two tokens for right-typed and type-flexible + break; + case 2369371622U when NormalizedEquals(name, "name"): + token = type == typeof(string) ? 1 : 4; + break; + case 4237030186U when NormalizedEquals(name, "birthdate"): + token = type == typeof(global::System.DateOnly) ? 2 : 5; + break; + + } + tokens[i] = token; + columnOffset++; + + } + return null; + } + public override global::Foo.User Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Foo.User result = new(); + foreach (var token in tokens) + { + switch (token) + { + case 0: + result.Id = reader.GetInt32(columnOffset); + break; + case 3: + result.Id = GetValue(reader, columnOffset); + break; + case 1: + result.Name = reader.IsDBNull(columnOffset) ? (string?)null : reader.GetString(columnOffset); + break; + case 4: + result.Name = reader.IsDBNull(columnOffset) ? (string?)null : GetValue(reader, columnOffset); + break; + case 2: + result.BirthDate = reader.GetFieldValue(columnOffset); + break; + case 5: + result.BirthDate = GetValue(reader, columnOffset); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + private sealed class CommandFactory0 : CommonCommandFactory // + { + internal static readonly CommandFactory0 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { BirthDate = default(global::System.DateOnly) }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "BirthDate"; + p.Direction = global::System.Data.ParameterDirection.Input; + p.Value = AsValue(typed.BirthDate); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { BirthDate = default(global::System.DateOnly) }); // expected shape + var ps = cmd.Parameters; + ps[0].Value = AsValue(typed.BirthDate); + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.QueryModel args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "BirthDate"; + p.Direction = global::System.Data.ParameterDirection.Input; + p.Value = AsValue(args.BirthDate); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.QueryModel args) + { + var ps = cmd.Parameters; + ps[0].Value = AsValue(args.BirthDate); + + } + + } + + + } +} +namespace System.Runtime.CompilerServices +{ + // this type is needed by the compiler to implement interceptors - it doesn't need to + // come from the runtime itself, though + + [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] + sealed file class InterceptsLocationAttribute : global::System.Attribute + { + public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber) + { + _ = path; + _ = lineNumber; + _ = columnNumber; + } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.txt b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.txt new file mode 100644 index 00000000..b5a7d042 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/DateOnly.net6.output.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 2 of 2 possible call-sites using 2 interceptors, 2 commands and 1 readers