Skip to content

Commit b7b68ff

Browse files
authored
(#215) MySQL test support. (#222)
* (#215) Added MySQL to list of resources. * (#215) MySQL Test Suite * (#215) Use Pomelo package instead of official MySQL package. * (#215) Enable connection retry on failure
1 parent b155848 commit b7b68ff

File tree

13 files changed

+375
-3
lines changed

13 files changed

+375
-3
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<PackageVersion Include="NSubstitute" Version="5.3.0" />
2828
<PackageVersion Include="NSwag.AspNetCore" Version="14.2.0" />
2929
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
30+
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.2.efcore.9.0.0" />
3031
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
3132
<PackageVersion Include="System.Formats.Asn1" Version="9.0.1" />
3233
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />

infra/main.bicep

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ module resources './resources.bicep' = {
4949
/*********************************************************************************/
5050

5151
output AZSQL_CONNECTION_STRING string = resources.outputs.AZSQL_CONNECTIONSTRING
52-
output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING
5352
output COSMOS_CONNECTION_STRING string = resources.outputs.COSMOS_CONNECTIONSTRING
53+
output MYSQL_CONNECTION_STRING string = resources.outputs.MYSQL_CONNECTIONSTRING
54+
output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING
5455
output SERVICE_ENDPOINT string = resources.outputs.SERVICE_ENDPOINT

infra/modules/mysql.bicep

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
targetScope = 'resourceGroup'
2+
3+
@description('The list of firewall rules to install')
4+
param firewallRules FirewallRule[] = [
5+
{ startIpAddress: '0.0.0.0', endIpAddress: '0.0.0.0' }
6+
]
7+
8+
@minLength(1)
9+
@description('The name of the test database to create')
10+
param databaseName string = 'unittests'
11+
12+
@minLength(1)
13+
@description('Primary location for all resources')
14+
param location string = resourceGroup().location
15+
16+
@description('The name of the SQL Server to create.')
17+
param sqlServerName string
18+
19+
@description('Optional - the SQL Server administrator password. If not provided, the username will be \'appadmin\'.')
20+
param sqlAdminUsername string = 'appadmin'
21+
22+
@secure()
23+
@description('Optional - SQL Server administrator password. If not provided, a random password will be generated.')
24+
param sqlAdminPassword string = newGuid()
25+
26+
@description('The list of tags to apply to all resources.')
27+
param tags object = {}
28+
29+
/*********************************************************************************/
30+
31+
resource mysql_server 'Microsoft.DBforMySQL/flexibleServers@2024-10-01-preview' = {
32+
name: sqlServerName
33+
location: location
34+
tags: tags
35+
sku: {
36+
name: 'Standard_B1ms'
37+
tier: 'Burstable'
38+
}
39+
properties: {
40+
administratorLogin: sqlAdminUsername
41+
administratorLoginPassword: sqlAdminPassword
42+
createMode: 'Default'
43+
authConfig: {
44+
activeDirectoryAuth: 'Disabled'
45+
passwordAuth: 'Enabled'
46+
}
47+
backup: {
48+
backupRetentionDays: 7
49+
geoRedundantBackup: 'Disabled'
50+
}
51+
highAvailability: {
52+
mode: 'Disabled'
53+
}
54+
storage: {
55+
storageSizeGB: 32
56+
autoGrow: 'Disabled'
57+
}
58+
version: '8.0.21'
59+
}
60+
61+
resource fw 'firewallRules@2023-12-30' = [ for (fwRule, idx) in firewallRules : {
62+
name: 'fw${idx}'
63+
properties: {
64+
startIpAddress: fwRule.startIpAddress
65+
endIpAddress: fwRule.endIpAddress
66+
}
67+
}]
68+
}
69+
70+
resource mysql_database 'Microsoft.DBforMySQL/flexibleServers/databases@2023-12-30' = {
71+
name: databaseName
72+
parent: mysql_server
73+
properties: {
74+
charset: 'ascii'
75+
collation: 'ascii_general_ci'
76+
}
77+
}
78+
79+
/*********************************************************************************/
80+
81+
#disable-next-line outputs-should-not-contain-secrets
82+
output MYSQL_CONNECTIONSTRING string = 'server=${mysql_server.properties.fullyQualifiedDomainName};database=${mysql_database.name};user=${mysql_server.properties.administratorLogin};password=${sqlAdminPassword}'
83+
84+
/*********************************************************************************/
85+
86+
type FirewallRule = {
87+
startIpAddress: string
88+
endIpAddress: string
89+
}

infra/resources.bicep

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var appServiceName = 'web-${resourceToken}'
3434
var azsqlServerName = 'sql-${resourceToken}'
3535
var cosmosServerName = 'cosmos-${resourceToken}'
3636
var pgsqlServerName = 'pgsql-${resourceToken}'
37+
var mysqlServerName = 'mysql-${resourceToken}'
3738

3839
var testDatabaseName = 'unittests'
3940
var cosmosContainerName = 'Movies'
@@ -78,6 +79,19 @@ module pgsql './modules/postgresql.bicep' = {
7879
}
7980
}
8081

82+
module mysql './modules/mysql.bicep' = {
83+
name: 'mysql-deployment-${resourceToken}'
84+
params: {
85+
location: location
86+
tags: tags
87+
databaseName: testDatabaseName
88+
firewallRules: clientIpFirewallRules
89+
sqlServerName: mysqlServerName
90+
sqlAdminUsername: sqlAdminUsername
91+
sqlAdminPassword: sqlAdminPassword
92+
}
93+
}
94+
8195
module cosmos './modules/cosmos.bicep' = {
8296
name: 'cosmos-deployment-${resourceToken}'
8397
params: {
@@ -109,6 +123,7 @@ module app_service './modules/appservice.bicep' = {
109123
/*********************************************************************************/
110124

111125
output AZSQL_CONNECTIONSTRING string = azuresql.outputs.AZSQL_CONNECTIONSTRING
112-
output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING
113126
output COSMOS_CONNECTIONSTRING string = cosmos.outputs.COSMOS_CONNECTIONSTRING
127+
output MYSQL_CONNECTIONSTRING string = mysql.outputs.MYSQL_CONNECTIONSTRING
128+
output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING
114129
output SERVICE_ENDPOINT string = app_service.outputs.SERVICE_ENDPOINT

infra/scripts/write-runsettings.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ $fileContents = @"
1313
<EnvironmentVariables>
1414
<DATASYNC_AZSQL_CONNECTIONSTRING>$($outputs.AZSQL_CONNECTION_STRING)</DATASYNC_AZSQL_CONNECTIONSTRING>
1515
<DATASYNC_COSMOS_CONNECTIONSTRING>$($outputs.COSMOS_CONNECTION_STRING)</DATASYNC_COSMOS_CONNECTIONSTRING>
16+
<DATASYNC_MYSQL_CONNECTIONSTRING>$($outputs.MYSQL_CONNECTION_STRING)</DATASYNC_MYSQL_CONNECTIONSTRING>
1617
<DATASYNC_PGSQL_CONNECTIONSTRING>$($outputs.PGSQL_CONNECTION_STRING)</DATASYNC_PGSQL_CONNECTIONSTRING>
1718
<DATASYNC_SERVICE_ENDPOINT>$($outputs.SERVICE_ENDPOINT)</DATASYNC_SERVICE_ENDPOINT>
1819
<ENABLE_SQL_LOGGING>true</ENABLE_SQL_LOGGING>

src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ public class EntityTableData : BaseEntityTableData
2121

2222
/// <inheritdoc />
2323
[Timestamp]
24-
public override byte[] Version { get; set; } = Array.Empty<byte>();
24+
public override byte[] Version { get; set; } = [];
2525
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.TestCommon;
6+
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using Microsoft.EntityFrameworkCore;
8+
using Xunit.Abstractions;
9+
10+
namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;
11+
12+
[ExcludeFromCodeCoverage]
13+
[Collection("LiveTestsCollection")]
14+
public class MysqlEntityTableRepository_Tests : RepositoryTests<MysqlEntityMovie>
15+
{
16+
#region Setup
17+
private readonly DatabaseFixture _fixture;
18+
private readonly Random random = new();
19+
private readonly string connectionString;
20+
private readonly List<MysqlEntityMovie> movies;
21+
private readonly Lazy<MysqlDbContext> _context;
22+
23+
public MysqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base()
24+
{
25+
this._fixture = fixture;
26+
this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING");
27+
if (!string.IsNullOrEmpty(this.connectionString))
28+
{
29+
this._context = new Lazy<MysqlDbContext>(() => MysqlDbContext.CreateContext(this.connectionString, output));
30+
this.movies = Context.Movies.AsNoTracking().ToList();
31+
}
32+
}
33+
34+
private MysqlDbContext Context { get => this._context.Value; }
35+
36+
protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
37+
38+
protected override Task<MysqlEntityMovie> GetEntityAsync(string id)
39+
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
40+
41+
protected override Task<int> GetEntityCountAsync()
42+
=> Task.FromResult(Context.Movies.Count());
43+
44+
protected override Task<IRepository<MysqlEntityMovie>> GetPopulatedRepositoryAsync()
45+
=> Task.FromResult<IRepository<MysqlEntityMovie>>(new EntityTableRepository<MysqlEntityMovie>(Context));
46+
47+
protected override Task<string> GetRandomEntityIdAsync(bool exists)
48+
=> Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
49+
#endregion
50+
51+
[SkippableFact]
52+
public void EntityTableRepository_BadDbSet_Throws()
53+
{
54+
Skip.IfNot(CanRunLiveTests());
55+
Action act = () => _ = new EntityTableRepository<EntityTableData>(Context);
56+
act.Should().Throw<ArgumentException>();
57+
}
58+
59+
[SkippableFact]
60+
public void EntityTableRepository_GoodDbSet_Works()
61+
{
62+
Skip.IfNot(CanRunLiveTests());
63+
Action act = () => _ = new EntityTableRepository<MysqlEntityMovie>(Context);
64+
act.Should().NotThrow();
65+
}
66+
67+
[SkippableFact]
68+
public async Task WrapExceptionAsync_ThrowsConflictException_WhenDbConcurrencyUpdateExceptionThrown()
69+
{
70+
Skip.IfNot(CanRunLiveTests());
71+
EntityTableRepository<MysqlEntityMovie> repository = await GetPopulatedRepositoryAsync() as EntityTableRepository<MysqlEntityMovie>;
72+
string id = await GetRandomEntityIdAsync(true);
73+
MysqlEntityMovie expectedPayload = await GetEntityAsync(id);
74+
75+
static Task innerAction() => throw new DbUpdateConcurrencyException("Concurrency exception");
76+
77+
Func<Task> act = async () => await repository.WrapExceptionAsync(id, innerAction);
78+
(await act.Should().ThrowAsync<HttpException>()).WithStatusCode(409).And.WithPayload(expectedPayload);
79+
}
80+
81+
[SkippableFact]
82+
public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExceptionThrown()
83+
{
84+
Skip.IfNot(CanRunLiveTests());
85+
EntityTableRepository<MysqlEntityMovie> repository = await GetPopulatedRepositoryAsync() as EntityTableRepository<MysqlEntityMovie>;
86+
string id = await GetRandomEntityIdAsync(true);
87+
MysqlEntityMovie expectedPayload = await GetEntityAsync(id);
88+
89+
static Task innerAction() => throw new DbUpdateException("Non-concurrency exception");
90+
91+
Func<Task> act = async () => await repository.WrapExceptionAsync(id, innerAction);
92+
await act.Should().ThrowAsync<RepositoryException>();
93+
}
94+
}

tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class DatabaseFixture
1111
{
1212
public bool AzureSqlIsInitialized { get; set; } = false;
1313
public bool CosmosIsInitialized { get; set; } = false;
14+
public bool MysqlIsInitialized { get; set; } = false;
1415
public bool PgIsInitialized { get; set; } = false;
1516
}
1617

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.EntityFrameworkCore;
6+
using CommunityToolkit.Datasync.Server.Test.Helpers;
7+
using CommunityToolkit.Datasync.TestCommon.Databases;
8+
using Microsoft.EntityFrameworkCore;
9+
using Xunit.Abstractions;
10+
11+
namespace CommunityToolkit.Datasync.Server.Test.Live;
12+
13+
[ExcludeFromCodeCoverage]
14+
[Collection("LiveTestsCollection")]
15+
public class MySQL_Controller_Tests : LiveControllerTests<MysqlEntityMovie>
16+
{
17+
#region Setup
18+
private readonly DatabaseFixture _fixture;
19+
private readonly Random random = new();
20+
private readonly string connectionString;
21+
private readonly List<MysqlEntityMovie> movies;
22+
23+
public MySQL_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base()
24+
{
25+
this._fixture = fixture;
26+
this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING");
27+
if (!string.IsNullOrEmpty(this.connectionString))
28+
{
29+
output.WriteLine($"MysqlIsInitialized = {this._fixture.MysqlIsInitialized}");
30+
Context = MysqlDbContext.CreateContext(this.connectionString, output, clearEntities: !this._fixture.MysqlIsInitialized);
31+
this.movies = Context.Movies.AsNoTracking().ToList();
32+
this._fixture.MysqlIsInitialized = true;
33+
}
34+
}
35+
36+
private MysqlDbContext Context { get; set; }
37+
38+
protected override string DriverName { get; } = "PgSQL";
39+
40+
protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
41+
42+
protected override Task<MysqlEntityMovie> GetEntityAsync(string id)
43+
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
44+
45+
protected override Task<int> GetEntityCountAsync()
46+
=> Task.FromResult(Context.Movies.Count());
47+
48+
protected override Task<IRepository<MysqlEntityMovie>> GetPopulatedRepositoryAsync()
49+
=> Task.FromResult<IRepository<MysqlEntityMovie>>(new EntityTableRepository<MysqlEntityMovie>(Context));
50+
51+
protected override Task<string> GetRandomEntityIdAsync(bool exists)
52+
=> Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
53+
#endregion
54+
}

tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
2020
<PackageReference Include="Microsoft.Spatial" />
2121
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
22+
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
2223
</ItemGroup>
2324

2425
<ItemGroup>

0 commit comments

Comments
 (0)