diff --git a/Directory.Packages.props b/Directory.Packages.props
index a2937ba..e316022 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,6 +27,7 @@
+
diff --git a/infra/main.bicep b/infra/main.bicep
index a5ce550..6f5979c 100644
--- a/infra/main.bicep
+++ b/infra/main.bicep
@@ -49,6 +49,7 @@ module resources './resources.bicep' = {
/*********************************************************************************/
output AZSQL_CONNECTION_STRING string = resources.outputs.AZSQL_CONNECTIONSTRING
-output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING
output COSMOS_CONNECTION_STRING string = resources.outputs.COSMOS_CONNECTIONSTRING
+output MYSQL_CONNECTION_STRING string = resources.outputs.MYSQL_CONNECTIONSTRING
+output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING
output SERVICE_ENDPOINT string = resources.outputs.SERVICE_ENDPOINT
diff --git a/infra/modules/mysql.bicep b/infra/modules/mysql.bicep
new file mode 100644
index 0000000..8d0397e
--- /dev/null
+++ b/infra/modules/mysql.bicep
@@ -0,0 +1,89 @@
+targetScope = 'resourceGroup'
+
+@description('The list of firewall rules to install')
+param firewallRules FirewallRule[] = [
+ { startIpAddress: '0.0.0.0', endIpAddress: '0.0.0.0' }
+]
+
+@minLength(1)
+@description('The name of the test database to create')
+param databaseName string = 'unittests'
+
+@minLength(1)
+@description('Primary location for all resources')
+param location string = resourceGroup().location
+
+@description('The name of the SQL Server to create.')
+param sqlServerName string
+
+@description('Optional - the SQL Server administrator password. If not provided, the username will be \'appadmin\'.')
+param sqlAdminUsername string = 'appadmin'
+
+@secure()
+@description('Optional - SQL Server administrator password. If not provided, a random password will be generated.')
+param sqlAdminPassword string = newGuid()
+
+@description('The list of tags to apply to all resources.')
+param tags object = {}
+
+/*********************************************************************************/
+
+resource mysql_server 'Microsoft.DBforMySQL/flexibleServers@2024-10-01-preview' = {
+ name: sqlServerName
+ location: location
+ tags: tags
+ sku: {
+ name: 'Standard_B1ms'
+ tier: 'Burstable'
+ }
+ properties: {
+ administratorLogin: sqlAdminUsername
+ administratorLoginPassword: sqlAdminPassword
+ createMode: 'Default'
+ authConfig: {
+ activeDirectoryAuth: 'Disabled'
+ passwordAuth: 'Enabled'
+ }
+ backup: {
+ backupRetentionDays: 7
+ geoRedundantBackup: 'Disabled'
+ }
+ highAvailability: {
+ mode: 'Disabled'
+ }
+ storage: {
+ storageSizeGB: 32
+ autoGrow: 'Disabled'
+ }
+ version: '8.0.21'
+ }
+
+ resource fw 'firewallRules@2023-12-30' = [ for (fwRule, idx) in firewallRules : {
+ name: 'fw${idx}'
+ properties: {
+ startIpAddress: fwRule.startIpAddress
+ endIpAddress: fwRule.endIpAddress
+ }
+ }]
+}
+
+resource mysql_database 'Microsoft.DBforMySQL/flexibleServers/databases@2023-12-30' = {
+ name: databaseName
+ parent: mysql_server
+ properties: {
+ charset: 'ascii'
+ collation: 'ascii_general_ci'
+ }
+}
+
+/*********************************************************************************/
+
+#disable-next-line outputs-should-not-contain-secrets
+output MYSQL_CONNECTIONSTRING string = 'server=${mysql_server.properties.fullyQualifiedDomainName};database=${mysql_database.name};user=${mysql_server.properties.administratorLogin};password=${sqlAdminPassword}'
+
+/*********************************************************************************/
+
+type FirewallRule = {
+ startIpAddress: string
+ endIpAddress: string
+}
diff --git a/infra/resources.bicep b/infra/resources.bicep
index ce72f1d..f42f39e 100644
--- a/infra/resources.bicep
+++ b/infra/resources.bicep
@@ -34,6 +34,7 @@ var appServiceName = 'web-${resourceToken}'
var azsqlServerName = 'sql-${resourceToken}'
var cosmosServerName = 'cosmos-${resourceToken}'
var pgsqlServerName = 'pgsql-${resourceToken}'
+var mysqlServerName = 'mysql-${resourceToken}'
var testDatabaseName = 'unittests'
var cosmosContainerName = 'Movies'
@@ -78,6 +79,19 @@ module pgsql './modules/postgresql.bicep' = {
}
}
+module mysql './modules/mysql.bicep' = {
+ name: 'mysql-deployment-${resourceToken}'
+ params: {
+ location: location
+ tags: tags
+ databaseName: testDatabaseName
+ firewallRules: clientIpFirewallRules
+ sqlServerName: mysqlServerName
+ sqlAdminUsername: sqlAdminUsername
+ sqlAdminPassword: sqlAdminPassword
+ }
+}
+
module cosmos './modules/cosmos.bicep' = {
name: 'cosmos-deployment-${resourceToken}'
params: {
@@ -109,6 +123,7 @@ module app_service './modules/appservice.bicep' = {
/*********************************************************************************/
output AZSQL_CONNECTIONSTRING string = azuresql.outputs.AZSQL_CONNECTIONSTRING
-output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING
output COSMOS_CONNECTIONSTRING string = cosmos.outputs.COSMOS_CONNECTIONSTRING
+output MYSQL_CONNECTIONSTRING string = mysql.outputs.MYSQL_CONNECTIONSTRING
+output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING
output SERVICE_ENDPOINT string = app_service.outputs.SERVICE_ENDPOINT
diff --git a/infra/scripts/write-runsettings.ps1 b/infra/scripts/write-runsettings.ps1
index fc07d32..cd288ae 100644
--- a/infra/scripts/write-runsettings.ps1
+++ b/infra/scripts/write-runsettings.ps1
@@ -13,6 +13,7 @@ $fileContents = @"
$($outputs.AZSQL_CONNECTION_STRING)
$($outputs.COSMOS_CONNECTION_STRING)
+ $($outputs.MYSQL_CONNECTION_STRING)
$($outputs.PGSQL_CONNECTION_STRING)
$($outputs.SERVICE_ENDPOINT)
true
diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs
index 70c2534..e8412b0 100644
--- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs
+++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs
@@ -21,5 +21,5 @@ public class EntityTableData : BaseEntityTableData
///
[Timestamp]
- public override byte[] Version { get; set; } = Array.Empty();
+ public override byte[] Version { get; set; } = [];
}
diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs
new file mode 100644
index 0000000..42d639d
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs
@@ -0,0 +1,94 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.TestCommon;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;
+
+[ExcludeFromCodeCoverage]
+[Collection("LiveTestsCollection")]
+public class MysqlEntityTableRepository_Tests : RepositoryTests
+{
+ #region Setup
+ private readonly DatabaseFixture _fixture;
+ private readonly Random random = new();
+ private readonly string connectionString;
+ private readonly List movies;
+ private readonly Lazy _context;
+
+ public MysqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base()
+ {
+ this._fixture = fixture;
+ this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING");
+ if (!string.IsNullOrEmpty(this.connectionString))
+ {
+ this._context = new Lazy(() => MysqlDbContext.CreateContext(this.connectionString, output));
+ this.movies = Context.Movies.AsNoTracking().ToList();
+ }
+ }
+
+ private MysqlDbContext Context { get => this._context.Value; }
+
+ protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
+
+ protected override Task GetEntityAsync(string id)
+ => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
+
+ protected override Task GetEntityCountAsync()
+ => Task.FromResult(Context.Movies.Count());
+
+ protected override Task> GetPopulatedRepositoryAsync()
+ => Task.FromResult>(new EntityTableRepository(Context));
+
+ protected override Task GetRandomEntityIdAsync(bool exists)
+ => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
+ #endregion
+
+ [SkippableFact]
+ public void EntityTableRepository_BadDbSet_Throws()
+ {
+ Skip.IfNot(CanRunLiveTests());
+ Action act = () => _ = new EntityTableRepository(Context);
+ act.Should().Throw();
+ }
+
+ [SkippableFact]
+ public void EntityTableRepository_GoodDbSet_Works()
+ {
+ Skip.IfNot(CanRunLiveTests());
+ Action act = () => _ = new EntityTableRepository(Context);
+ act.Should().NotThrow();
+ }
+
+ [SkippableFact]
+ public async Task WrapExceptionAsync_ThrowsConflictException_WhenDbConcurrencyUpdateExceptionThrown()
+ {
+ Skip.IfNot(CanRunLiveTests());
+ EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository;
+ string id = await GetRandomEntityIdAsync(true);
+ MysqlEntityMovie expectedPayload = await GetEntityAsync(id);
+
+ static Task innerAction() => throw new DbUpdateConcurrencyException("Concurrency exception");
+
+ Func act = async () => await repository.WrapExceptionAsync(id, innerAction);
+ (await act.Should().ThrowAsync()).WithStatusCode(409).And.WithPayload(expectedPayload);
+ }
+
+ [SkippableFact]
+ public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExceptionThrown()
+ {
+ Skip.IfNot(CanRunLiveTests());
+ EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository;
+ string id = await GetRandomEntityIdAsync(true);
+ MysqlEntityMovie expectedPayload = await GetEntityAsync(id);
+
+ static Task innerAction() => throw new DbUpdateException("Non-concurrency exception");
+
+ Func act = async () => await repository.WrapExceptionAsync(id, innerAction);
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs
index 602c268..55d3c34 100644
--- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs
+++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs
@@ -11,6 +11,7 @@ public class DatabaseFixture
{
public bool AzureSqlIsInitialized { get; set; } = false;
public bool CosmosIsInitialized { get; set; } = false;
+ public bool MysqlIsInitialized { get; set; } = false;
public bool PgIsInitialized { get; set; } = false;
}
diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs
new file mode 100644
index 0000000..83eff51
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Server.EntityFrameworkCore;
+using CommunityToolkit.Datasync.Server.Test.Helpers;
+using CommunityToolkit.Datasync.TestCommon.Databases;
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace CommunityToolkit.Datasync.Server.Test.Live;
+
+[ExcludeFromCodeCoverage]
+[Collection("LiveTestsCollection")]
+public class MySQL_Controller_Tests : LiveControllerTests
+{
+ #region Setup
+ private readonly DatabaseFixture _fixture;
+ private readonly Random random = new();
+ private readonly string connectionString;
+ private readonly List movies;
+
+ public MySQL_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base()
+ {
+ this._fixture = fixture;
+ this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING");
+ if (!string.IsNullOrEmpty(this.connectionString))
+ {
+ output.WriteLine($"MysqlIsInitialized = {this._fixture.MysqlIsInitialized}");
+ Context = MysqlDbContext.CreateContext(this.connectionString, output, clearEntities: !this._fixture.MysqlIsInitialized);
+ this.movies = Context.Movies.AsNoTracking().ToList();
+ this._fixture.MysqlIsInitialized = true;
+ }
+ }
+
+ private MysqlDbContext Context { get; set; }
+
+ protected override string DriverName { get; } = "PgSQL";
+
+ protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
+
+ protected override Task GetEntityAsync(string id)
+ => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
+
+ protected override Task GetEntityCountAsync()
+ => Task.FromResult(Context.Movies.Count());
+
+ protected override Task> GetPopulatedRepositoryAsync()
+ => Task.FromResult>(new EntityTableRepository(Context));
+
+ protected override Task GetRandomEntityIdAsync(bool exists)
+ => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
+ #endregion
+}
diff --git a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj
index a846b3a..ed10564 100644
--- a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj
+++ b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj
@@ -19,6 +19,7 @@
+
diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs
new file mode 100644
index 0000000..bfc135f
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.EntityFrameworkCore;
+using Xunit.Abstractions;
+
+namespace CommunityToolkit.Datasync.TestCommon.Databases;
+
+[ExcludeFromCodeCoverage]
+public class MysqlDbContext(DbContextOptions options) : BaseDbContext(options)
+{
+ public static MysqlDbContext CreateContext(string connectionString, ITestOutputHelper output = null, bool clearEntities = true)
+ {
+ if (string.IsNullOrEmpty(connectionString))
+ {
+ throw new ArgumentNullException(nameof(connectionString));
+ }
+
+ DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder()
+ .UseMySql(connectionString: connectionString, serverVersion: ServerVersion.AutoDetect(connectionString), options => options.EnableRetryOnFailure())
+ .EnableLogging(output);
+ MysqlDbContext context = new(optionsBuilder.Options);
+
+ context.InitializeDatabase(clearEntities);
+ context.PopulateDatabase();
+ return context;
+ }
+
+ internal void InitializeDatabase(bool clearEntities)
+ {
+ Database.EnsureCreated();
+
+ if (clearEntities)
+ {
+ ExecuteRawSqlOnEachEntity(@"DELETE FROM {0}");
+ }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().Property(m => m.UpdatedAt)
+ .ValueGeneratedOnAddOrUpdate();
+
+ modelBuilder.Entity().Property(m => m.Version)
+ .IsRowVersion();
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs
new file mode 100644
index 0000000..644ef26
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs
@@ -0,0 +1,64 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Server.EntityFrameworkCore;
+using CommunityToolkit.Datasync.TestCommon.Models;
+using System.ComponentModel.DataAnnotations;
+
+namespace CommunityToolkit.Datasync.TestCommon.Databases;
+
+[ExcludeFromCodeCoverage]
+public class MysqlEntityMovie : EntityTableData, IMovie, IEquatable
+{
+ ///
+ /// True if the movie won the oscar for Best Picture
+ ///
+ public bool BestPictureWinner { get; set; }
+
+ ///
+ /// The running time of the movie
+ ///
+ [Required]
+ [Range(60, 360)]
+ public int Duration { get; set; }
+
+ ///
+ /// The MPAA rating for the movie, if available.
+ ///
+ public MovieRating Rating { get; set; } = MovieRating.Unrated;
+
+ ///
+ /// The release date of the movie.
+ ///
+ [Required]
+ public DateOnly ReleaseDate { get; set; }
+
+ ///
+ /// The title of the movie.
+ ///
+ [Required]
+ [StringLength(128, MinimumLength = 2)]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// The year that the movie was released.
+ ///
+ [Required]
+ [Range(1920, 2030)]
+ public int Year { get; set; }
+
+ ///
+ /// Determines if this movie has the same content as another movie.
+ ///
+ /// The other movie
+ /// true if the content is the same
+ public bool Equals(IMovie other)
+ => other != null
+ && other.BestPictureWinner == BestPictureWinner
+ && other.Duration == Duration
+ && other.Rating == Rating
+ && other.ReleaseDate == ReleaseDate
+ && other.Title == Title
+ && other.Year == Year;
+}
diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj
index db877fe..f77dbd3 100644
--- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj
+++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj
@@ -18,5 +18,6 @@
+