Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Entity Framework Core Support #14109

Merged
merged 35 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4b74d48
Add UmbracoEFCore project
Zeegaan Apr 13, 2023
025b3c2
Add EFCore composer
Zeegaan Apr 13, 2023
548ff67
Add Locking Mechanisms
Zeegaan Apr 13, 2023
b7e2331
Add scope interfaces
Zeegaan Apr 13, 2023
02884c2
Add excecute scalar extension method
Zeegaan Apr 13, 2023
d2a0c73
fix up query in locking mechanism
Zeegaan Apr 13, 2023
f583359
Add scoping
Zeegaan Apr 13, 2023
3f87ecc
Add scoping
Zeegaan Apr 13, 2023
069f748
Merge remote-tracking branch 'origin/v12/feature/ef-core' into v12/fe…
Zeegaan Apr 13, 2023
bc7174a
Add test DbContext classes
Zeegaan Apr 14, 2023
0cb0036
add locking test of EFCore
Zeegaan Apr 14, 2023
2e61490
Creat ScopedFileSystemsTests
Zeegaan Apr 14, 2023
5a4d2ff
Add EFCoreScopeInfrastructureScopeLockTests
Zeegaan Apr 14, 2023
4703c1d
Add EFCoreScopeInfrastructureScopeTests
Zeegaan Apr 14, 2023
ae4425d
Add EFCoreScopeNotificationsTest.cs
Zeegaan Apr 14, 2023
8b9cc85
Add EFCoreScopeTest.cs
Zeegaan Apr 14, 2023
2ee4a3b
Remake AddUmbracoEFCoreContext to use connection string
Zeegaan Apr 17, 2023
38ad286
Remove unused code from extension method
Zeegaan Apr 18, 2023
b2ecddf
Reference EFCore reference to Cms.csproj
Zeegaan Apr 18, 2023
47d4403
Remove unused parameter
Zeegaan Apr 18, 2023
a8116e0
Dont have default implementation, breaking change instead
Zeegaan Apr 18, 2023
5a13951
Add compatability suppression file
Zeegaan Apr 18, 2023
ade6600
Merge branch 'v12/dev' into v12/feature/ef-core
Zeegaan Apr 19, 2023
5e24b46
Merge remote-tracking branch 'origin/v12/dev' into v12/feature/ef-core
bergmania May 3, 2023
7dd607c
Updated EFCore packages
bergmania May 3, 2023
5092b9f
Use timespan for timeout
bergmania May 3, 2023
99d45a2
Allow overriding default EF Core actions
bergmania May 3, 2023
6365b2d
Option lifetime needs to be singleton
bergmania May 3, 2023
4d0943c
Use given timeout in database call
Zeegaan May 3, 2023
51852a7
dont use timespan.zero, use null instead
Zeegaan May 3, 2023
49a1285
Use variable timeout
Zeegaan May 3, 2023
8920797
Update test to use locking mechanism
Zeegaan May 3, 2023
25a181f
Remove unneccesary duplicate code
Zeegaan May 3, 2023
8970e05
Change to catch proper exception number
Zeegaan May 11, 2023
a845a5c
Merge branch 'v12/dev' into v12/feature/ef-core
Zeegaan May 11, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;

namespace Umbraco.Extensions;

public static class DbContextExtensions
{
/// <summary>
/// Executes a raw SQL query and returns the result.
/// </summary>
/// <param name="database">The database.</param>
/// <param name="sql">The sql query.</param>
/// <param name="parameters">The list of db parameters.</param>
/// <param name="commandType">The command type.</param>
/// <param name="commandTimeOut">The amount of time to wait before the command times out.</param>
/// <typeparam name="T">the type to return.</typeparam>
/// <returns>Returns an object of the given type T.</returns>
public static async Task<T?> ExecuteScalarAsync<T>(this DatabaseFacade database, string sql, List<DbParameter>? parameters = null, CommandType commandType = CommandType.Text, TimeSpan? commandTimeOut = null)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(sql);

await using DbCommand dbCommand = database.GetDbConnection().CreateCommand();

if (database.CurrentTransaction is not null)
{
dbCommand.Transaction = database.CurrentTransaction.GetDbTransaction();
}

if (dbCommand.Connection?.State != ConnectionState.Open)
{
await dbCommand.Connection!.OpenAsync();
}

dbCommand.CommandText = sql;
dbCommand.CommandType = commandType;
if (commandTimeOut is not null)
{
dbCommand.CommandTimeout = (int)commandTimeOut.Value.TotalSeconds;
}

if (parameters != null)
{
dbCommand.Parameters.AddRange(parameters.ToArray());
}

var result = await dbCommand.ExecuteScalarAsync();
return (T?)result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DistributedLocking;
using Umbraco.Cms.Persistence.EFCore.Locking;
using Umbraco.Cms.Persistence.EFCore.Scoping;

namespace Umbraco.Extensions;

public static class UmbracoEFCoreServiceCollectionExtensions
{
public delegate void DefaultEFCoreOptionsAction(DbContextOptionsBuilder options, string? providerName, string? connectionString);

public static IServiceCollection AddUmbracoEFCoreContext<T>(this IServiceCollection services, string connectionString, string providerName, DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction = null)
where T : DbContext
{
defaultEFCoreOptionsAction ??= DefaultOptionsAction;

services.AddDbContext<T>(
options =>
{
defaultEFCoreOptionsAction(options, providerName, connectionString);
},
optionsLifetime: ServiceLifetime.Singleton);

services.AddDbContextFactory<T>(options =>
{
defaultEFCoreOptionsAction(options, providerName, connectionString);
});

services.AddUnique<IAmbientEFCoreScopeStack<T>, AmbientEFCoreScopeStack<T>>();
services.AddUnique<IEFCoreScopeAccessor<T>, EFCoreScopeAccessor<T>>();
services.AddUnique<IEFCoreScopeProvider<T>, EFCoreScopeProvider<T>>();
services.AddSingleton<IDistributedLockingMechanism, SqliteEFCoreDistributedLockingMechanism<T>>();
services.AddSingleton<IDistributedLockingMechanism, SqlServerEFCoreDistributedLockingMechanism<T>>();

return services;
}

private static void DefaultOptionsAction(DbContextOptionsBuilder options, string? providerName, string? connectionString)
{
if (connectionString.IsNullOrWhiteSpace())
{
return;
}

switch (providerName)
{
case "Microsoft.Data.Sqlite":
options.UseSqlite(connectionString);
break;
case "Microsoft.Data.SqlClient":
options.UseSqlServer(connectionString);
break;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DistributedLocking;
using Umbraco.Cms.Core.DistributedLocking.Exceptions;
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Persistence.EFCore.Scoping;
using Umbraco.Extensions;

namespace Umbraco.Cms.Persistence.EFCore.Locking;

internal class SqlServerEFCoreDistributedLockingMechanism<T> : IDistributedLockingMechanism
where T : DbContext
{
private readonly IOptionsMonitor<ConnectionStrings> _connectionStrings;
private readonly IOptionsMonitor<GlobalSettings> _globalSettings;
private readonly ILogger<SqlServerEFCoreDistributedLockingMechanism<T>> _logger;
private readonly Lazy<IEFCoreScopeAccessor<T>> _scopeAccessor; // Hooray it's a circular dependency.

/// <summary>
/// Initializes a new instance of the <see cref="SqlServerDistributedLockingMechanism" /> class.
/// </summary>
public SqlServerEFCoreDistributedLockingMechanism(
ILogger<SqlServerEFCoreDistributedLockingMechanism<T>> logger,
Lazy<IEFCoreScopeAccessor<T>> scopeAccessor,
IOptionsMonitor<GlobalSettings> globalSettings,
IOptionsMonitor<ConnectionStrings> connectionStrings)
{
_logger = logger;
_scopeAccessor = scopeAccessor;
_globalSettings = globalSettings;
_connectionStrings = connectionStrings;
}

public bool HasActiveRelatedScope => _scopeAccessor.Value.AmbientScope is not null;

/// <inheritdoc />
public bool Enabled => _connectionStrings.CurrentValue.IsConnectionStringConfigured() &&
string.Equals(_connectionStrings.CurrentValue.ProviderName, "Microsoft.Data.SqlClient", StringComparison.InvariantCultureIgnoreCase) && _scopeAccessor.Value.AmbientScope is not null;

/// <inheritdoc />
public IDistributedLock ReadLock(int lockId, TimeSpan? obtainLockTimeout = null)
{
obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingReadLockDefaultTimeout;
return new SqlServerDistributedLock(this, lockId, DistributedLockType.ReadLock, obtainLockTimeout.Value);
}

/// <inheritdoc />
public IDistributedLock WriteLock(int lockId, TimeSpan? obtainLockTimeout = null)
{
obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingWriteLockDefaultTimeout;
return new SqlServerDistributedLock(this, lockId, DistributedLockType.WriteLock, obtainLockTimeout.Value);
}

private class SqlServerDistributedLock : IDistributedLock
{
private readonly SqlServerEFCoreDistributedLockingMechanism<T> _parent;
private readonly TimeSpan _timeout;

public SqlServerDistributedLock(
SqlServerEFCoreDistributedLockingMechanism<T> parent,
int lockId,
DistributedLockType lockType,
TimeSpan timeout)
{
_parent = parent;
_timeout = timeout;
LockId = lockId;
LockType = lockType;

_parent._logger.LogDebug("Requesting {lockType} for id {id}", LockType, LockId);

try
{
switch (lockType)
{
case DistributedLockType.ReadLock:
ObtainReadLock();
break;
case DistributedLockType.WriteLock:
ObtainWriteLock();
break;
default:
throw new ArgumentOutOfRangeException(nameof(lockType), lockType, @"Unsupported lockType");
}
}
catch (SqlException ex) when (ex.Number == 1222)
{
if (LockType == DistributedLockType.ReadLock)
{
throw new DistributedReadLockTimeoutException(LockId);
}

throw new DistributedWriteLockTimeoutException(LockId);
}

_parent._logger.LogDebug("Acquired {lockType} for id {id}", LockType, LockId);
}

public int LockId { get; }

public DistributedLockType LockType { get; }

public void Dispose() =>
// Mostly no op, cleaned up by completing transaction in scope.
_parent._logger.LogDebug("Dropped {lockType} for id {id}", LockType, LockId);

public override string ToString()
=> $"SqlServerDistributedLock({LockId}, {LockType}";

private void ObtainReadLock()
{
IEfCoreScope<T>? scope = _parent._scopeAccessor.Value.AmbientScope;

if (scope is null)
{
throw new PanicException("No ambient scope");
}

scope.ExecuteWithContextAsync<Task>(async dbContext =>
{
if (dbContext.Database.CurrentTransaction is null)
{
throw new InvalidOperationException(
"SqlServerDistributedLockingMechanism requires a transaction to function.");
}

if (dbContext.Database.CurrentTransaction.GetDbTransaction().IsolationLevel <
IsolationLevel.ReadCommitted)
{
throw new InvalidOperationException(
"A transaction with minimum ReadCommitted isolation level is required.");
}

await dbContext.Database.ExecuteSqlRawAsync($"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};");

var number = await dbContext.Database.ExecuteScalarAsync<int?>($"SELECT value FROM dbo.umbracoLock WITH (REPEATABLEREAD) WHERE id={LockId}");

if (number == null)
{
// ensure we are actually locking!
throw new ArgumentException(@$"LockObject with id={LockId} does not exist.", nameof(LockId));
}
}).GetAwaiter().GetResult();
}

private void ObtainWriteLock()
{
IEfCoreScope<T>? scope = _parent._scopeAccessor.Value.AmbientScope;
if (scope is null)
{
throw new PanicException("No ambient scope");
}

scope.ExecuteWithContextAsync<Task>(async dbContext =>
{
if (dbContext.Database.CurrentTransaction is null)
{
throw new InvalidOperationException(
"SqlServerDistributedLockingMechanism requires a transaction to function.");
}

if (dbContext.Database.CurrentTransaction.GetDbTransaction().IsolationLevel < IsolationLevel.ReadCommitted)
{
throw new InvalidOperationException(
"A transaction with minimum ReadCommitted isolation level is required.");
}

await dbContext.Database.ExecuteSqlRawAsync($"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};");

var rowsAffected = await dbContext.Database.ExecuteSqlAsync(@$"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id={LockId}");

if (rowsAffected == 0)
{
// ensure we are actually locking!
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}).GetAwaiter().GetResult();
}
}
}
Loading