diff --git a/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj b/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj
index 725115c..f1c4e1f 100644
--- a/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj
+++ b/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj
@@ -16,10 +16,10 @@
-
+
-
+
diff --git a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs
index bcb3ca9..ab1fddb 100644
--- a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs
+++ b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs
@@ -14,7 +14,7 @@ public interface ITwitchApiProxy {
/// The Twitch access token. These are the credentials used for all requests.
///
TwitchAccessToken? OAuth { get; set; }
-
+
///
/// The Twitch app configuration. These are used for all requests.
///
diff --git a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs
index fc0f708..9e5b63b 100644
--- a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs
+++ b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs
@@ -33,7 +33,7 @@ public class TwitchApiProxy : ITwitchApiProxy {
/// Initializes a new instance of the class.
///
public TwitchApiProxy() {
- TwitchAppConfig = new() {
+ TwitchAppConfig = new TwitchAppConfig {
ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"),
ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"),
ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT")
@@ -46,21 +46,27 @@ public TwitchApiProxy() {
/// The access token.
/// The refresh token.
/// When the token expires (utc).
- /// The client id of the registered twitch app, uses environment variable
- /// "TWITCH_BOT_CLIENT_ID" when null.
- /// The client secret of the registered twitch app, uses environment variable
- /// "TWITCH_BOT_CLIENT_SECRET" when null.
- /// The url to redirect to from the registered twitch app, uses environment variable
- /// "TWITCH_BOT_CLIENT_REDIRECT" when null.
- public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null,
+ ///
+ /// The client id of the registered twitch app, uses environment variable
+ /// "TWITCH_BOT_CLIENT_ID" when null.
+ ///
+ ///
+ /// The client secret of the registered twitch app, uses environment variable
+ /// "TWITCH_BOT_CLIENT_SECRET" when null.
+ ///
+ ///
+ /// The url to redirect to from the registered twitch app, uses environment variable
+ /// "TWITCH_BOT_CLIENT_REDIRECT" when null.
+ ///
+ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null,
string? clientSecret = null, string? clientRedirect = null) {
OAuth = new TwitchAccessToken {
AccessToken = token,
RefreshToken = refreshToken,
ExpiresUtc = tokenExpires
};
-
- TwitchAppConfig = new() {
+
+ TwitchAppConfig = new TwitchAppConfig {
ClientId = clientId ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"),
ClientSecret = clientSecret ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"),
ClientRedirect = clientRedirect ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT")
@@ -74,14 +80,14 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires,
///
public virtual TwitchAccessToken? OAuth { get; set; }
-
+
///
public virtual TwitchAppConfig? TwitchAppConfig { get; set; }
///
public virtual async Task CreateAccessToken(string code, CancellationToken token = new()) {
ITwitchAPI api = GetApi();
- AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, TwitchAppConfig?.ClientSecret,
+ AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, TwitchAppConfig?.ClientSecret,
TwitchAppConfig?.ClientRedirect);
if (null == response) {
return null;
@@ -101,7 +107,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires,
if (string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientSecret) || string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientId)) {
return null;
}
-
+
ITwitchAPI api = GetApi();
RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, TwitchAppConfig?.ClientSecret, TwitchAppConfig?.ClientId);
if (null == response) {
@@ -145,7 +151,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires,
}
///
- public virtual async Task GetUserEmail(CancellationToken token = new()) {
+ public virtual async Task GetUserEmail(CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
GetUsersResponse? response = await api.Helix.Users.GetUsersAsync();
@@ -158,7 +164,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires,
}
///
- public virtual async Task> GetUserModChannels(string userId) {
+ public virtual async Task> GetUserModChannels(string userId) {
using var client = new HttpClient();
var ret = new List();
@@ -189,7 +195,7 @@ public virtual async Task> GetUserModChanne
}
///
- public virtual async Task> BanChannelUsers(string channelId, string botId,
+ public virtual async Task> BanChannelUsers(string channelId, string botId,
IEnumerable<(string Id, string Username)> users, string reason, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
@@ -223,7 +229,7 @@ public virtual async Task> BanChannelUsers(string chann
}
///
- public virtual async Task> GetChannelUsers(string channelId, string botId,
+ public virtual async Task> GetChannelUsers(string channelId, string botId,
CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
@@ -247,7 +253,7 @@ public virtual async Task> GetChannelUsers(string channelI
}
///
- public virtual async Task> GetChannelsLive(IEnumerable userIds) {
+ public virtual async Task> GetChannelsLive(IEnumerable userIds) {
ITwitchAPI api = GetApi();
// We can only query 100 at a time, so throttle the search.
@@ -271,7 +277,7 @@ public virtual async Task> GetChannelsLive(IEnumerable
- public virtual async Task> GetChannelMods(string channelId, CancellationToken token = new()) {
+ public virtual async Task> GetChannelMods(string channelId, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
@@ -298,7 +304,7 @@ public virtual async Task> GetChannelsLive(IEnumerable
- public virtual async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) {
+ public virtual async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
await api.Helix.Moderation.AddChannelModeratorAsync(channelId, userId);
diff --git a/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs
index 4e5222c..ef9460e 100644
--- a/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs
+++ b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs
@@ -1,21 +1,21 @@
namespace Nullinside.Api.Common.Twitch;
///
-/// The configuration for a twitch app that provides OAuth tokens.
+/// The configuration for a twitch app that provides OAuth tokens.
///
public class TwitchAppConfig {
///
- /// The client id.
+ /// The client id.
///
public string? ClientId { get; set; }
-
+
///
- /// The client secret.
+ /// The client secret.
///
public string? ClientSecret { get; set; }
-
+
///
- /// A registered URL that the Twitch API is allowed to redirect to on our website.
+ /// A registered URL that the Twitch API is allowed to redirect to on our website.
///
public string? ClientRedirect { get; set; }
}
\ No newline at end of file
diff --git a/src/Nullinside.Api.Model/Ddl/User.cs b/src/Nullinside.Api.Model/Ddl/User.cs
index aad7a38..c5d4c00 100644
--- a/src/Nullinside.Api.Model/Ddl/User.cs
+++ b/src/Nullinside.Api.Model/Ddl/User.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel.DataAnnotations;
+
using Microsoft.EntityFrameworkCore;
namespace Nullinside.Api.Model.Ddl;
@@ -60,6 +62,7 @@ public class User : ITableModel {
///
/// The last timestamp of when the user logged into the site.
///
+ [Timestamp]
public DateTime UpdatedOn { get; set; }
///
@@ -92,6 +95,8 @@ public void OnModelCreating(ModelBuilder modelBuilder) {
.HasMaxLength(255);
entity.Property(e => e.Token)
.HasMaxLength(255);
+ entity.Property(e => e.UpdatedOn)
+ .IsRowVersion();
// TODO: Add the other strings in this file with lengths
});
}
diff --git a/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs
new file mode 100644
index 0000000..292ab93
--- /dev/null
+++ b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs
@@ -0,0 +1,314 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Nullinside.Api.Model;
+
+#nullable disable
+
+namespace Nullinside.Api.Model.Migrations
+{
+ [DbContext(typeof(NullinsideContext))]
+ [Migration("20250228164901_UserTableTimestamp")]
+ partial class UserTableTimestamp
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("IsDockerComposeProject")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Notes")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("ServerDir")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayName");
+
+ b.HasIndex("IsDockerComposeProject", "Name");
+
+ b.ToTable("DockerDeployments");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.FeatureToggle", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("Feature")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.ToTable("FeatureToggle");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchBan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("BannedUserTwitchId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("ChannelId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.ToTable("TwitchBan");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("TwitchId")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("TwitchUsername")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TwitchId")
+ .IsUnique();
+
+ b.ToTable("TwitchUser");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserBannedOutsideOfBotLogs", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("Channel")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("Reason")
+ .HasColumnType("longtext");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TwitchId")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("TwitchUsername")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("TwitchUserBannedOutsideOfBotLogs");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserChatLogs", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("Channel")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("Message")
+ .HasColumnType("longtext");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TwitchId")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("TwitchUsername")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("TwitchUserChatLogs");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("BanKnownBots")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("TwitchUserConfig");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("CreatedOn")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("IsBanned")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Token")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("TwitchId")
+ .HasColumnType("longtext");
+
+ b.Property("TwitchLastScanned")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TwitchRefreshToken")
+ .HasColumnType("longtext");
+
+ b.Property("TwitchToken")
+ .HasColumnType("longtext");
+
+ b.Property("TwitchTokenExpiration")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TwitchUsername")
+ .HasColumnType("longtext");
+
+ b.Property("UpdatedOn")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ b.Property("Role")
+ .HasColumnType("int");
+
+ b.Property("RoleAdded")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserRoles");
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b =>
+ {
+ b.HasOne("Nullinside.Api.Model.Ddl.User", null)
+ .WithOne("TwitchConfig")
+ .HasForeignKey("Nullinside.Api.Model.Ddl.TwitchUserConfig", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b =>
+ {
+ b.HasOne("Nullinside.Api.Model.Ddl.User", null)
+ .WithMany("Roles")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b =>
+ {
+ b.Navigation("Roles");
+
+ b.Navigation("TwitchConfig");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs
new file mode 100644
index 0000000..600728d
--- /dev/null
+++ b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs
@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using MySql.EntityFrameworkCore.Metadata;
+
+#nullable disable
+
+namespace Nullinside.Api.Model.Migrations
+{
+ ///
+ public partial class UserTableTimestamp : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "UpdatedOn",
+ table: "Users",
+ type: "datetime(6)",
+ rowVersion: true,
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "datetime(6)")
+ .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "UpdatedOn",
+ table: "Users",
+ type: "datetime(6)",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "datetime(6)",
+ oldRowVersion: true)
+ .OldAnnotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn);
+ }
+ }
+}
diff --git a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs
index c739d53..040f4d1 100644
--- a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs
+++ b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs
@@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b =>
@@ -247,6 +247,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("longtext");
b.Property("UpdatedOn")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime(6)");
b.HasKey("Id");
diff --git a/src/Nullinside.Api.Model/Shared/DatabaseLock.cs b/src/Nullinside.Api.Model/Shared/DatabaseLock.cs
new file mode 100644
index 0000000..64e2e02
--- /dev/null
+++ b/src/Nullinside.Api.Model/Shared/DatabaseLock.cs
@@ -0,0 +1,94 @@
+using Microsoft.EntityFrameworkCore;
+
+using Nullinside.Api.Model;
+
+namespace Nullinside.Api.Common;
+
+///
+/// Handles acquiring a lock at the database level.
+///
+/// Requires a transaction wrapping its functionality.
+public class DatabaseLock : IDisposable {
+ ///
+ /// The database context.
+ ///
+ private INullinsideContext _mysqlDbContext;
+ ///
+ /// The lock name used on the previous lock.
+ ///
+ private string? _name;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The database context.
+ /// Must provide parameter.
+ public DatabaseLock(INullinsideContext mysqlDbContext) {
+ _mysqlDbContext = mysqlDbContext ?? throw new ArgumentNullException(nameof(mysqlDbContext));
+ }
+
+ ///
+ /// Acquires the lock.
+ ///
+ /// Waits indefinitely.
+ /// The name of the lock. MUST BE A HARD CODED VALUE
+ /// The cancellation token.
+ /// True if successful, false otherwise.
+ public async Task GetLock(string name, CancellationToken cancellationToken = new()) {
+ if (string.IsNullOrWhiteSpace(name)) {
+ return false;
+ }
+
+ // This is only used with hard coded names.
+#pragma warning disable EF1002
+ await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT GET_LOCK('{name}', -1)", cancellationToken: cancellationToken);
+#pragma warning restore EF1002
+ _name = name;
+ return true;
+ }
+
+ ///
+ /// Releases the lock.
+ ///
+ /// The name of the lock. MUST BE A HARD CODED VALUE
+ /// The cancellation token.
+ public async Task ReleaseLock(string name, CancellationToken cancellationToken = new()) {
+ if (!string.IsNullOrWhiteSpace(name) && !name.Equals(_name, StringComparison.InvariantCultureIgnoreCase)) {
+ // This is only used with hard coded names.
+#pragma warning disable EF1002
+ await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT RELEASE_LOCK('{_name}')", cancellationToken: cancellationToken);
+#pragma warning restore EF1002
+ }
+
+ // This is only used with hard coded names.
+#pragma warning disable EF1002
+ await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT RELEASE_LOCK('{name}')", cancellationToken: cancellationToken);
+#pragma warning restore EF1002
+ _name = null;
+ }
+
+ ///
+ /// Disposes of resources.
+ ///
+ /// True if dispose called, false if finalizer.
+ protected virtual void Dispose(bool disposing) {
+ if (disposing) {
+ if (!string.IsNullOrWhiteSpace(_name)) {
+ Task.WaitAll(this.ReleaseLock(_name));
+ }
+ }
+ }
+
+ ///
+ public void Dispose() {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Finalizes instance of the class.
+ ///
+ ~DatabaseLock() {
+ Dispose(false);
+ }
+}
\ No newline at end of file