From 4dab255fc4e80dd7a244a149b41848d4585f9625 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Fri, 14 Oct 2022 17:13:25 +0200 Subject: [PATCH] docs: Add persistence layer (MSSQL) to WeatherForecast example --- .gitattributes | 3 +- examples/WeatherForecast/Packages.props | 18 ++++--- examples/WeatherForecast/WeatherForecast.sln | 6 +++ .../WeatherDataContext.cs | 37 ++++++++++++++ .../WeatherDataReadOnlyContext.cs | 37 ++++++++++++++ .../WeatherDataWriteOnlyContext.cs | 31 ++++++++++++ .../WeatherForecast.Contexts.csproj | 13 +++++ .../src/WeatherForecast.Entities/HasId.cs | 20 ++++++++ .../WeatherForecast.Entities/Temperature.cs | 42 ++++++++++------ .../WeatherForecast.Entities/WeatherData.cs | 22 +++++--- .../WeatherForecast.Entities.csproj | 2 + .../WeatherDataReadOnlyRepository.cs | 2 +- .../src/WeatherForecast/Program.cs | 22 ++++++-- .../WeatherForecast/WeatherForecast.csproj | 2 + .../WeatherForecast.Test.csproj | 2 +- .../WeatherForecastContainer.cs | 50 ++++++++++++++++--- 16 files changed, 269 insertions(+), 40 deletions(-) create mode 100644 examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataContext.cs create mode 100644 examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataReadOnlyContext.cs create mode 100644 examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataWriteOnlyContext.cs create mode 100644 examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherForecast.Contexts.csproj create mode 100644 examples/WeatherForecast/src/WeatherForecast.Entities/HasId.cs diff --git a/.gitattributes b/.gitattributes index 310176ed1..49ccf7597 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,9 @@ # Autodetect text files * text=auto -# Definitively text files +# Definitively text files *.cs text *.cake text # Git lfs +*.crt filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text diff --git a/examples/WeatherForecast/Packages.props b/examples/WeatherForecast/Packages.props index cb0944b15..b646ff7e2 100644 --- a/examples/WeatherForecast/Packages.props +++ b/examples/WeatherForecast/Packages.props @@ -1,12 +1,16 @@ - - - - - - - + + + + + + + + + + + diff --git a/examples/WeatherForecast/WeatherForecast.sln b/examples/WeatherForecast/WeatherForecast.sln index df836eedd..1f515a78d 100644 --- a/examples/WeatherForecast/WeatherForecast.sln +++ b/examples/WeatherForecast/WeatherForecast.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers", "..\..\src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherForecast", "src\WeatherForecast\WeatherForecast.csproj", "{40712E50-0FC0-47AA-A9BE-98AA2FB73E59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherForecast.Contexts", "src\WeatherForecast.Contexts\WeatherForecast.Contexts.csproj", "{86938290-5D7D-43B5-8146-4614FB5820F4}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherForecast.Entities", "src\WeatherForecast.Entities\WeatherForecast.Entities.csproj", "{53CAFB42-4AEB-43F9-B6A1-DAA8EE46F174}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherForecast.Interactors", "src\WeatherForecast.Interactors\WeatherForecast.Interactors.csproj", "{6582ADC8-7DE3-4183-95DF-75D63B784023}" @@ -31,6 +33,10 @@ Global {40712E50-0FC0-47AA-A9BE-98AA2FB73E59}.Debug|Any CPU.Build.0 = Debug|Any CPU {40712E50-0FC0-47AA-A9BE-98AA2FB73E59}.Release|Any CPU.ActiveCfg = Release|Any CPU {40712E50-0FC0-47AA-A9BE-98AA2FB73E59}.Release|Any CPU.Build.0 = Release|Any CPU + {86938290-5D7D-43B5-8146-4614FB5820F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86938290-5D7D-43B5-8146-4614FB5820F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86938290-5D7D-43B5-8146-4614FB5820F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86938290-5D7D-43B5-8146-4614FB5820F4}.Release|Any CPU.Build.0 = Release|Any CPU {53CAFB42-4AEB-43F9-B6A1-DAA8EE46F174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {53CAFB42-4AEB-43F9-B6A1-DAA8EE46F174}.Debug|Any CPU.Build.0 = Debug|Any CPU {53CAFB42-4AEB-43F9-B6A1-DAA8EE46F174}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataContext.cs b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataContext.cs new file mode 100644 index 000000000..bf12eab45 --- /dev/null +++ b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataContext.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using WeatherForecast.Entities; + +namespace WeatherForecast.Contexts; + +[PublicAPI] +public sealed class WeatherDataContext : DbContext +{ + public WeatherDataContext(DbContextOptions options) : base(options) + { + } + + public DbSet WeatherData { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Map all read-only properties. There is not [Mapped] attribute like [NotMapped]. + modelBuilder.Entity().Property(weatherData => weatherData.Id); + modelBuilder.Entity().Property(weatherData => weatherData.Date); + modelBuilder.Entity().Property(temperature => temperature.Id); + modelBuilder.Entity().Property(temperature => temperature.UnitName); + modelBuilder.Entity().Property(temperature => temperature.UnitSymbol); + modelBuilder.Entity().Property(temperature => temperature.Value); + modelBuilder.Entity().Property(temperature => temperature.Measured); + modelBuilder.Entity().HasMany(weatherData => weatherData.Temperatures).WithOne().HasForeignKey(temperature => temperature.BelongsTo); + + var weatherDataSeed = Enumerable.Range(0, 30).Select(_ => Guid.NewGuid()).Select((id, day) => new WeatherData(id, DateTime.Today.AddDays(day))).ToList(); + var temperatureSeed = weatherDataSeed.SelectMany(data => Enumerable.Range(0, 23).Select(hour => Temperature.Celsius(data.Id, Random.Shared.Next(-10, 30), data.Date.AddHours(hour)))).ToList(); + + modelBuilder.Entity().HasData(temperatureSeed); + modelBuilder.Entity().HasData(weatherDataSeed); + base.OnModelCreating(modelBuilder); + } +} diff --git a/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataReadOnlyContext.cs b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataReadOnlyContext.cs new file mode 100644 index 000000000..049cd0f0c --- /dev/null +++ b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataReadOnlyContext.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using WeatherForecast.Entities; +using WeatherForecast.Repositories; + +namespace WeatherForecast.Contexts; + +[PublicAPI] +public sealed class WeatherDataReadOnlyContext : IWeatherDataReadOnlyRepository +{ + private readonly WeatherDataContext _context; + + public WeatherDataReadOnlyContext(WeatherDataContext context) + { + _context = context; + _context.Database.EnsureCreated(); + } + + public Task> GetAllAsync() + { + throw new NotImplementedException(); + } + + public Task> GetAllAsync(string latitude, string longitude, DateTime from, DateTime to) + { + return Task.FromResult>(_context.WeatherData.Include(property => property.Temperatures).OrderBy(weatherData => weatherData.Date).Take(to.Subtract(from).Days)); + } + + public Task GetAsync(Guid id) + { + throw new NotImplementedException(); + } +} diff --git a/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataWriteOnlyContext.cs b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataWriteOnlyContext.cs new file mode 100644 index 000000000..608ac3cbe --- /dev/null +++ b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherDataWriteOnlyContext.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using WeatherForecast.Entities; +using WeatherForecast.Repositories; + +namespace WeatherForecast.Contexts; + +[PublicAPI] +public sealed class WeatherDataWriteOnlyContext : IWeatherDataWriteOnlyRepository +{ + public WeatherDataWriteOnlyContext(WeatherDataContext context) + { + _ = context; + } + + public Task CreateAsync(WeatherData weatherData) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(WeatherData weatherData) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(WeatherData weatherData) + { + throw new NotImplementedException(); + } +} diff --git a/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherForecast.Contexts.csproj b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherForecast.Contexts.csproj new file mode 100644 index 000000000..6ddfc1c86 --- /dev/null +++ b/examples/WeatherForecast/src/WeatherForecast.Contexts/WeatherForecast.Contexts.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + + + + + + + + + diff --git a/examples/WeatherForecast/src/WeatherForecast.Entities/HasId.cs b/examples/WeatherForecast/src/WeatherForecast.Entities/HasId.cs new file mode 100644 index 000000000..db5ea6ec4 --- /dev/null +++ b/examples/WeatherForecast/src/WeatherForecast.Entities/HasId.cs @@ -0,0 +1,20 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace WeatherForecast.Entities; + +[PublicAPI] +public abstract class HasId +{ + [JsonConstructor] + public HasId(Guid id) + { + Id = id; + } + + [Key] + [JsonPropertyName("id")] + public Guid Id { get; } +} diff --git a/examples/WeatherForecast/src/WeatherForecast.Entities/Temperature.cs b/examples/WeatherForecast/src/WeatherForecast.Entities/Temperature.cs index 71fa667d9..d9d54b91f 100644 --- a/examples/WeatherForecast/src/WeatherForecast.Entities/Temperature.cs +++ b/examples/WeatherForecast/src/WeatherForecast.Entities/Temperature.cs @@ -1,39 +1,51 @@ using System; +using System.Text.Json.Serialization; using JetBrains.Annotations; namespace WeatherForecast.Entities; [PublicAPI] -public readonly struct Temperature +public sealed class Temperature : HasId { - private Temperature(string unitName, string unitSymbol, double value, DateTime measured) + [JsonConstructor] + public Temperature(Guid id, Guid belongsTo, string unitName, string unitSymbol, double value, DateTime measured) : base(id) { + BelongsTo = belongsTo; UnitName = unitName; UnitSymbol = unitSymbol; Value = value; Measured = measured; } - public static Temperature Kelvin(double value, DateTime measured) - { - return new Temperature("Kelvin", "K", value, measured); - } - - public static Temperature Celsius(double value, DateTime measured) - { - return new Temperature("degree Celsius", "°C", value, measured); - } + public static Temperature AbsoluteZero { get; } = Temperature.Kelvin(Guid.Empty, 0, DateTime.MinValue); - public static Temperature Fahrenheit(double value, DateTime measured) - { - return new Temperature("degree Fahrenheit", "°F", value, measured); - } + [JsonPropertyName("belongsTo")] + public Guid BelongsTo { get; } + [JsonPropertyName("unitName")] public string UnitName { get; } + [JsonPropertyName("unitSymbol")] public string UnitSymbol { get; } + [JsonPropertyName("value")] public double Value { get; } + [JsonPropertyName("measured")] public DateTime Measured { get; } + + public static Temperature Kelvin(Guid belongsTo, double value, DateTime measured) + { + return new Temperature(Guid.NewGuid(), belongsTo, "Kelvin", "K", value, measured); + } + + public static Temperature Celsius(Guid belongsTo, double value, DateTime measured) + { + return new Temperature(Guid.NewGuid(), belongsTo, "degree Celsius", "°C", value, measured); + } + + public static Temperature Fahrenheit(Guid belongsTo, double value, DateTime measured) + { + return new Temperature(Guid.NewGuid(), belongsTo, "degree Fahrenheit", "°F", value, measured); + } } diff --git a/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherData.cs b/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherData.cs index ae2324da3..15aad91b0 100644 --- a/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherData.cs +++ b/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherData.cs @@ -1,27 +1,37 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using JetBrains.Annotations; namespace WeatherForecast.Entities; [PublicAPI] -public readonly struct WeatherData +public sealed class WeatherData : HasId { - public WeatherData(DateTime date, IEnumerable measurements) + public WeatherData(Guid id, DateTime date) : this(id, date, new List()) + { + // Entity Framework constructor. + } + + [JsonConstructor] + public WeatherData(Guid id, DateTime date, IList temperatures) : base(id) { - IReadOnlyCollection temperatures = measurements.ToList(); Date = date; - Minimum = temperatures.OrderBy(temperature => temperature.Value).FirstOrDefault(); - Maximum = temperatures.OrderBy(temperature => temperature.Value).LastOrDefault(); + Minimum = temperatures.OrderBy(temperature => temperature.Value).DefaultIfEmpty(Temperature.AbsoluteZero).First(); + Maximum = temperatures.OrderBy(temperature => temperature.Value).DefaultIfEmpty(Temperature.AbsoluteZero).Last(); Temperatures = temperatures; } + [JsonPropertyName("date")] public DateTime Date { get; } + [JsonIgnore] public Temperature Minimum { get; } + [JsonIgnore] public Temperature Maximum { get; } - public IEnumerable Temperatures { get; } + [JsonPropertyName("temperatures")] + public IList Temperatures { get; } } diff --git a/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherForecast.Entities.csproj b/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherForecast.Entities.csproj index 1975642a4..aa2dc7a92 100644 --- a/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherForecast.Entities.csproj +++ b/examples/WeatherForecast/src/WeatherForecast.Entities/WeatherForecast.Entities.csproj @@ -5,5 +5,7 @@ + + diff --git a/examples/WeatherForecast/src/WeatherForecast.Repositories/WeatherDataReadOnlyRepository.cs b/examples/WeatherForecast/src/WeatherForecast.Repositories/WeatherDataReadOnlyRepository.cs index 475ace153..9c402d4eb 100644 --- a/examples/WeatherForecast/src/WeatherForecast.Repositories/WeatherDataReadOnlyRepository.cs +++ b/examples/WeatherForecast/src/WeatherForecast.Repositories/WeatherDataReadOnlyRepository.cs @@ -20,7 +20,7 @@ public Task> GetAllAsync() public Task> GetAllAsync(string latitude, string longitude, DateTime from, DateTime to) { - return Task.FromResult(Enumerable.Range(0, to.Subtract(from).Days).Select(day => DateTime.Today.AddDays(day)).Select(day => new WeatherData(day, Enumerable.Range(0, 12).Select(hour => Temperature.Celsius(Random.Value.Next(-10, 30), DateTime.Today.AddHours(hour)))))); + return Task.FromResult(Enumerable.Range(0, to.Subtract(from).Days).Select(_ => Guid.NewGuid()).Select((id, day) => new WeatherData(id, DateTime.Today.AddDays(day), Enumerable.Range(0, 23).Select(hour => Temperature.Celsius(id, Random.Value.Next(-10, 30), DateTime.Today.AddDays(day).AddHours(hour))).ToList()))); } public Task GetAsync(Guid id) diff --git a/examples/WeatherForecast/src/WeatherForecast/Program.cs b/examples/WeatherForecast/src/WeatherForecast/Program.cs index 1f5c819cd..11ba0e306 100644 --- a/examples/WeatherForecast/src/WeatherForecast/Program.cs +++ b/examples/WeatherForecast/src/WeatherForecast/Program.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Fast.Components.FluentUI; +using WeatherForecast.Contexts; using WeatherForecast.Interactors.SearchCityOrZipCode; using WeatherForecast.Repositories; @@ -9,9 +12,22 @@ builder.Services.AddServerSideBlazor(); builder.Services.AddHttpClient(); builder.Services.AddFluentUIComponents(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + +if (string.IsNullOrWhiteSpace(connectionString)) +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); +} var app = builder.Build(); app.UseExceptionHandler("/Error"); diff --git a/examples/WeatherForecast/src/WeatherForecast/WeatherForecast.csproj b/examples/WeatherForecast/src/WeatherForecast/WeatherForecast.csproj index 283f74a2e..bc3a734b7 100644 --- a/examples/WeatherForecast/src/WeatherForecast/WeatherForecast.csproj +++ b/examples/WeatherForecast/src/WeatherForecast/WeatherForecast.csproj @@ -5,9 +5,11 @@ false + + diff --git a/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecast.Test.csproj b/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecast.Test.csproj index d777a6778..2ee7c717b 100644 --- a/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecast.Test.csproj +++ b/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecast.Test.csproj @@ -12,7 +12,7 @@ - + diff --git a/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecastContainer.cs b/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecastContainer.cs index e34bd02d2..cf7580ae0 100644 --- a/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecastContainer.cs +++ b/examples/WeatherForecast/tests/WeatherForecast.Test/WeatherForecastContainer.cs @@ -3,7 +3,9 @@ using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; using JetBrains.Annotations; using Xunit; @@ -16,7 +18,11 @@ public sealed class WeatherForecastContainer : HttpClient, IAsyncLifetime private static readonly WeatherForecastImage Image = new(); - private readonly IDockerContainer _container; + private readonly IDockerNetwork _weatherForecastNetwork; + + private readonly IDockerContainer _mssqlContainer; + + private readonly IDockerContainer _weatherForecastContainer; public WeatherForecastContainer() : base(new HttpClientHandler @@ -24,13 +30,33 @@ public WeatherForecastContainer() ServerCertificateCustomValidationCallback = (_, certificate, _, _) => Certificate.Equals(certificate) }) { - _container = new TestcontainersBuilder() + const string weatherForecastStorage = "weatherForecastStorage"; + + var mssqlConfiguration = new MsSqlTestcontainerConfiguration(); + mssqlConfiguration.Password = Guid.NewGuid().ToString("D"); + mssqlConfiguration.Database = Guid.NewGuid().ToString("D"); + + var connectionString = $"server={weatherForecastStorage};user id=sa;password={mssqlConfiguration.Password};database={mssqlConfiguration.Database}"; + + _weatherForecastNetwork = new TestcontainersNetworkBuilder() + .WithName(Guid.NewGuid().ToString("D")) + .Build(); + + _mssqlContainer = new TestcontainersBuilder() + .WithDatabase(mssqlConfiguration) + .WithNetwork(_weatherForecastNetwork) + .WithNetworkAliases(weatherForecastStorage) + .Build(); + + _weatherForecastContainer = new TestcontainersBuilder() .WithImage(Image) - .WithStartupCallback((_, ct) => Task.Delay(TimeSpan.FromSeconds(1), ct)) // For demonstration only, use a wait strategy instead. + .WithNetwork(_weatherForecastNetwork) .WithPortBinding(WeatherForecastImage.HttpsPort, true) .WithEnvironment("ASPNETCORE_URLS", "https://+") .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", WeatherForecastImage.CertificateFilePath) .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", WeatherForecastImage.CertificatePassword) + .WithEnvironment("ConnectionStrings__DefaultConnection", connectionString) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(WeatherForecastImage.HttpsPort)) .Build(); } @@ -39,7 +65,13 @@ public async Task InitializeAsync() await Image.InitializeAsync() .ConfigureAwait(false); - await _container.StartAsync() + await _weatherForecastNetwork.CreateAsync() + .ConfigureAwait(false); + + await _mssqlContainer.StartAsync() + .ConfigureAwait(false); + + await _weatherForecastContainer.StartAsync() .ConfigureAwait(false); } @@ -48,7 +80,13 @@ public async Task DisposeAsync() await Image.DisposeAsync() .ConfigureAwait(false); - await _container.DisposeAsync() + await _weatherForecastContainer.DisposeAsync() + .ConfigureAwait(false); + + await _mssqlContainer.DisposeAsync() + .ConfigureAwait(false); + + await _weatherForecastNetwork.DeleteAsync() .ConfigureAwait(false); } @@ -56,7 +94,7 @@ public void SetBaseAddress() { try { - var uriBuilder = new UriBuilder("https", _container.Hostname, _container.GetMappedPublicPort(WeatherForecastImage.HttpsPort)); + var uriBuilder = new UriBuilder("https", _weatherForecastContainer.Hostname, _weatherForecastContainer.GetMappedPublicPort(WeatherForecastImage.HttpsPort)); BaseAddress = uriBuilder.Uri; } catch