Skip to content

Commit

Permalink
publicize sqllite datetime column conversion (#945)
Browse files Browse the repository at this point in the history
  • Loading branch information
dpvreony committed Jan 14, 2022
1 parent de2abf8 commit 124aa7d
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 1 deletion.
141 changes: 141 additions & 0 deletions src/Whipstaff.Entityframework.Relational/ModelBuilderHelpers.cs
@@ -0,0 +1,141 @@
// Copyright (c) 2020 DHGMS Solutions and Contributors. All rights reserved.
// DHGMS Solutions and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace Whipstaff.EntityFramework
{
/// <summary>
/// Entity Framework Model Builder Helpers for SQL Lite.
/// </summary>
public static class ModelBuilderHelpers
{
/// <summary>
/// Converts all DBSet entities that contain a DateTimeOffset column to use a long database type within SQL Lite.
/// Is used to workaround a limitation in SQL lite where you can't store as a DateTimeOffset and the workaround
/// is to use a string or DateTime and lose the precision. Instead, so you don't need to adjust your model to cater
/// for SQL lite, you can retain the ability of databases that do support it, but use SQL lite for testing.
/// The caveat is that SQL lite loses timezone precision as it converts everything to UTC, but then you should
/// probably be storing the data as UTC anyway.
/// </summary>
/// <param name="modelBuilder">Entity Framework Model Builder being configured.</param>
public static void ConvertAllDateTimeOffSetPropertiesOnModelBuilderToLong(ModelBuilder modelBuilder)
{
if (modelBuilder == null)
{
throw new ArgumentNullException(nameof(modelBuilder));
}

foreach (var mutableEntityType in modelBuilder.Model.GetEntityTypes())
{
InternalConvertAllDateTimeOffSetPropertiesOnMutableEntityToLong(
modelBuilder,
mutableEntityType);
}
}

/// <summary>
/// Converts all properties on a DBSet entity that are a DateTimeOffset column to use a long database type within SQL Lite.
/// Is used to workaround a limitation in SQL lite where you can't store as a DateTimeOffset and the workaround
/// is to use a string or DateTime and lose the precision. Instead, so you don't need to adjust your model to cater
/// for SQL lite, you can retain the ability of databases that do support it, but use SQL lite for testing.
/// The caveat is that SQL lite loses timezone precision as it converts everything to UTC, but then you should
/// probably be storing the data as UTC anyway.
/// </summary>
/// <param name="modelBuilder">Entity Framework Model Builder being configured.</param>
/// <param name="mutableEntityType">Mutable Entity Type Representing the DBSet to check.</param>
public static void ConvertAllDateTimeOffSetPropertiesOnMutableEntityToLong(
ModelBuilder modelBuilder,
IMutableEntityType mutableEntityType)
{
if (modelBuilder == null)
{
throw new ArgumentNullException(nameof(modelBuilder));
}

if (mutableEntityType == null)
{
throw new ArgumentNullException(nameof(mutableEntityType));
}

InternalConvertAllDateTimeOffSetPropertiesOnMutableEntityToLong(
modelBuilder,
mutableEntityType);
}

/// <summary>
/// Converts a property properties on a DBSet entity that are a DateTimeOffset column to use a long database type within SQL Lite.
/// Is used to workaround a limitation in SQL lite where you can't store as a DateTimeOffset and the workaround
/// is to use a string or DateTime and lose the precision. Instead, so you don't need to adjust your model to cater
/// for SQL lite, you can retain the ability of databases that do support it, but use SQL lite for testing.
/// The caveat is that SQL lite loses timezone precision as it converts everything to UTC, but then you should
/// probably be storing the data as UTC anyway.
/// </summary>
/// <param name="modelBuilder">Entity Framework Model Builder being configured.</param>
/// <param name="entityClrType">The CLR type of the Entity represented as a DBSet.</param>
/// <param name="propertyName">The name of the property that's to be converted from DateTimeOffset.</param>
public static void ConvertDateTimeOffSetPropertyToLong(
ModelBuilder modelBuilder,
Type entityClrType,
string propertyName)
{
if (modelBuilder == null)
{
throw new ArgumentNullException(nameof(modelBuilder));
}

if (entityClrType == null)
{
throw new ArgumentNullException(nameof(modelBuilder));
}

if (string.IsNullOrWhiteSpace(propertyName))
{
throw new ArgumentNullException(nameof(propertyName));
}

InternalConvertDateTimeOffSetPropertyToLong(
modelBuilder,
entityClrType,
propertyName);
}

/// <summary>
/// Gets a value convertor for converting a date time offset to unix time milliseconds as a long.
/// </summary>
/// <returns>Value convertor.</returns>
public static ValueConverter<DateTimeOffset, long> GetDateTimeOffSetToUnixTimeMillisecondsLongValueConverter() =>
new ValueConverter<DateTimeOffset, long>(
offset => offset.ToUnixTimeMilliseconds(),
milliseconds => DateTimeOffset.FromUnixTimeMilliseconds(milliseconds));

private static void InternalConvertAllDateTimeOffSetPropertiesOnMutableEntityToLong(ModelBuilder modelBuilder, IMutableEntityType mutableEntityType)
{
foreach (var p in mutableEntityType.GetProperties())
{
if (p.ClrType == typeof(DateTimeOffset))
{
ConvertDateTimeOffSetPropertyToLong(
modelBuilder,
mutableEntityType.ClrType,
p.Name);
}
}
}

private static void InternalConvertDateTimeOffSetPropertyToLong(
ModelBuilder modelBuilder,
Type entityClrType,
string propertyName)
{
modelBuilder.Entity(entityClrType)
.Property(propertyName)
.HasColumnType("INTEGER")
.HasConversion(GetDateTimeOffSetToUnixTimeMillisecondsLongValueConverter());
}
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.1" />
</ItemGroup>

</Project>
@@ -0,0 +1,177 @@
// Copyright (c) 2020 DHGMS Solutions and Contributors. All rights reserved.
// DHGMS Solutions and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System;
using System.Data.Common;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Whipstaff.EntityFramework;
using Xunit;

namespace Whipstaff.UnitTests.EntityFramework.Relational
{
/// <summary>
/// Unit Tests for SQL Lite DateTimeOffset ordering.
/// </summary>
public static class SqlLiteDateTimeOffsetOrdering
{
/// <summary>
/// Represents a test entity.
/// </summary>
public sealed class TestEntity
{
/// <summary>
/// Gets or sets the Id.
/// </summary>
public int Id { get; set; }

/// <summary>
/// Gets or sets the date time with offset.
/// </summary>
public DateTimeOffset DateTimeOffset { get; set; }
}

/// <summary>
/// Represents the db context common functionality for testing.
/// </summary>
public class BaseTestDbContext : DbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="BaseTestDbContext"/> class.
/// </summary>
/// <param name="options">Database context options.</param>
public BaseTestDbContext(DbContextOptions options)
: base(options)
{
}

/// <summary>
/// Gets or sets the test entity db set.
/// </summary>
public DbSet<TestEntity> TestEntity => Set<TestEntity>();
}

/// <summary>
/// Represents a test db context that has the database model specific options injected into the constructor.
/// </summary>
public sealed class TestWithContextOptionsDbContext : BaseTestDbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="TestWithContextOptionsDbContext"/> class.
/// </summary>
/// <param name="options">Database context options.</param>
public TestWithContextOptionsDbContext(DbContextOptions options)
: base(options)
{
// TODO: check the extension the model is defined in.
}
}

/// <summary>
/// Represents a test db context that sets the database model up via the OnModelCreating override method.
/// </summary>
public sealed class TestWithOnModelCreatingDbContext : BaseTestDbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="TestWithOnModelCreatingDbContext"/> class.
/// </summary>
/// <param name="options">Database context options.</param>
public TestWithOnModelCreatingDbContext(DbContextOptions options)
: base(options)
{
}

/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

ModelBuilderHelpers.ConvertAllDateTimeOffSetPropertiesOnModelBuilderToLong(modelBuilder);
}
}

/// <summary>
/// Unit Tests for the OrderBy method.
/// </summary>
public sealed class OrderByMethod
{
/// <summary>
/// Test to ensure data is returned.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task ReturnsData()
{
var dbContextOptionsBuilder = new DbContextOptionsBuilder();
using (var connection = CreateInMemoryDatabase())
{
_ = dbContextOptionsBuilder.UseSqlite(connection);

using (var dbContext = new TestWithOnModelCreatingDbContext(dbContextOptionsBuilder.Options))
{
_ = dbContext.Database.EnsureCreated();

var result = await dbContext.TestEntity
.Where(GetSelector())
.ToArrayAsync()
.ConfigureAwait(false);
}
}
}

/// <summary>
/// Test to ensure data is returned.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task ReturnsData2()
{
var dbContextOptionsBuilder = new DbContextOptionsBuilder();

using (var connection = CreateInMemoryDatabase())
{
_ = dbContextOptionsBuilder.UseSqlite(connection);

var modelBuilder = SqliteConventionSetBuilder.CreateModelBuilder();
_ = modelBuilder.Entity<TestEntity>();

ModelBuilderHelpers.ConvertAllDateTimeOffSetPropertiesOnModelBuilderToLong(modelBuilder);

var model = modelBuilder.FinalizeModel();

dbContextOptionsBuilder = dbContextOptionsBuilder.UseModel(model);

using (var dbContext = new TestWithContextOptionsDbContext(dbContextOptionsBuilder.Options))
{
_ = dbContext.Database.EnsureCreated();

var result = await dbContext.TestEntity
.Where(GetSelector())
.ToArrayAsync()
.ConfigureAwait(false);
}
}
}

private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");

connection.Open();

return connection;
}

private static Expression<Func<TestEntity, bool>> GetSelector()
{
return entity => entity.Id > 0;
}
}
}
}
1 change: 1 addition & 0 deletions src/Whipstaff.UnitTests/Whipstaff.UnitTests.csproj
Expand Up @@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\Whipstaff.AspNetCore\Whipstaff.AspNetCore.csproj" />
<ProjectReference Include="..\Whipstaff.Core\Whipstaff.Core.csproj" />
<ProjectReference Include="..\Whipstaff.Entityframework.Relational\Whipstaff.Entityframework.Relational.csproj" />
<ProjectReference Include="..\Whipstaff.EntityFramework\Whipstaff.EntityFramework.csproj" />
<ProjectReference Include="..\Whipstaff.Fakes\Whipstaff.Testing.csproj" />
<ProjectReference Include="..\Whipstaff.MediatR.EntityFrameworkCore\Whipstaff.MediatR.EntityFrameworkCore.csproj" />
Expand Down
8 changes: 7 additions & 1 deletion src/Whipstaff.sln
Expand Up @@ -41,7 +41,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Whipstaff.Wpf.Mahapps", "Wh
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Whipstaff.Windows", "Whipstaff.Windows\Whipstaff.Windows.csproj", "{CA159AE1-8795-43F7-A7DC-3A2682C8E6CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Whipstaff.ReactiveUI", "Whipstaff.ReactiveUI\Whipstaff.ReactiveUI.csproj", "{FC16B508-41FF-47CE-A16A-A8AB3B26309A}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Whipstaff.ReactiveUI", "Whipstaff.ReactiveUI\Whipstaff.ReactiveUI.csproj", "{FC16B508-41FF-47CE-A16A-A8AB3B26309A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Whipstaff.Entityframework.Relational", "Whipstaff.Entityframework.Relational\Whipstaff.Entityframework.Relational.csproj", "{783BF3CB-4095-4C5F-8C0C-D5E17979CC56}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -125,6 +127,10 @@ Global
{FC16B508-41FF-47CE-A16A-A8AB3B26309A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC16B508-41FF-47CE-A16A-A8AB3B26309A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC16B508-41FF-47CE-A16A-A8AB3B26309A}.Release|Any CPU.Build.0 = Release|Any CPU
{783BF3CB-4095-4C5F-8C0C-D5E17979CC56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{783BF3CB-4095-4C5F-8C0C-D5E17979CC56}.Debug|Any CPU.Build.0 = Debug|Any CPU
{783BF3CB-4095-4C5F-8C0C-D5E17979CC56}.Release|Any CPU.ActiveCfg = Release|Any CPU
{783BF3CB-4095-4C5F-8C0C-D5E17979CC56}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down

0 comments on commit 124aa7d

Please sign in to comment.