diff --git a/.idea/.idea.AzureAppConfigurationEmulator/.idea/sqldialects.xml b/.idea/.idea.AzureAppConfigurationEmulator/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/.idea/.idea.AzureAppConfigurationEmulator/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/AzureAppConfigurationEmulator/AzureAppConfigurationEmulator.csproj b/src/AzureAppConfigurationEmulator/AzureAppConfigurationEmulator.csproj index 2d419b7..2779327 100644 --- a/src/AzureAppConfigurationEmulator/AzureAppConfigurationEmulator.csproj +++ b/src/AzureAppConfigurationEmulator/AzureAppConfigurationEmulator.csproj @@ -8,12 +8,7 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + diff --git a/src/AzureAppConfigurationEmulator/Contexts/ApplicationDbContext.cs b/src/AzureAppConfigurationEmulator/Contexts/ApplicationDbContext.cs deleted file mode 100644 index 48113a6..0000000 --- a/src/AzureAppConfigurationEmulator/Contexts/ApplicationDbContext.cs +++ /dev/null @@ -1,26 +0,0 @@ -using AzureAppConfigurationEmulator.Entities; -using Microsoft.EntityFrameworkCore; - -namespace AzureAppConfigurationEmulator.Contexts; - -public class ApplicationDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet ConfigurationSettings => Set(); - - public DbSet ConfigurationSettingRevisions => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(typeBuilder => - { - typeBuilder.HasKey(setting => new { setting.Key, setting.Label }); - }); - - modelBuilder.Entity(typeBuilder => - { - typeBuilder.HasKey(revision => new { revision.Key, revision.Label, revision.ValidFrom }); - }); - } -} diff --git a/src/AzureAppConfigurationEmulator/Entities/ConfigurationSetting.cs b/src/AzureAppConfigurationEmulator/Entities/ConfigurationSetting.cs index 1bbef95..6db870d 100644 --- a/src/AzureAppConfigurationEmulator/Entities/ConfigurationSetting.cs +++ b/src/AzureAppConfigurationEmulator/Entities/ConfigurationSetting.cs @@ -1,19 +1,20 @@ namespace AzureAppConfigurationEmulator.Entities; public class ConfigurationSetting( - string eTag, + string etag, string key, - string label, + string? label, string? contentType, string? value, DateTimeOffset lastModified, - bool isReadOnly) + bool locked, + IDictionary? tags) { - public string ETag { get; set; } = eTag; + public string Etag { get; set; } = etag; public string Key { get; set; } = key; - public string Label { get; set; } = label; + public string? Label { get; set; } = label; public string? ContentType { get; set; } = contentType; @@ -21,5 +22,7 @@ public class ConfigurationSetting( public DateTimeOffset LastModified { get; set; } = lastModified; - public bool IsReadOnly { get; set; } = isReadOnly; + public bool Locked { get; set; } = locked; + + public IDictionary? Tags { get; set; } = tags; } diff --git a/src/AzureAppConfigurationEmulator/Entities/ConfigurationSettingRevision.cs b/src/AzureAppConfigurationEmulator/Entities/ConfigurationSettingRevision.cs deleted file mode 100644 index c377d78..0000000 --- a/src/AzureAppConfigurationEmulator/Entities/ConfigurationSettingRevision.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace AzureAppConfigurationEmulator.Entities; - -public class ConfigurationSettingRevision( - string eTag, - string key, - string label, - string? contentType, - string? value, - DateTimeOffset lastModified, - bool isReadOnly, - DateTimeOffset validFrom, - DateTimeOffset? validTo) -{ - public ConfigurationSettingRevision(ConfigurationSetting setting) : this( - setting.ETag, - setting.Key, - setting.Label, - setting.ContentType, - setting.Value, - setting.LastModified, - setting.IsReadOnly, - setting.LastModified, - null) - { - } - - public string ETag { get; set; } = eTag; - - public string Key { get; set; } = key; - - public string Label { get; set; } = label; - - public string? ContentType { get; set; } = contentType; - - public string? Value { get; set; } = value; - - public DateTimeOffset LastModified { get; set; } = lastModified; - - public bool IsReadOnly { get; set; } = isReadOnly; - - public DateTimeOffset ValidFrom { get; set; } = validFrom; - - public DateTimeOffset? ValidTo { get; set; } = validTo; -} diff --git a/src/AzureAppConfigurationEmulator/Extensions/HostingExtensions.cs b/src/AzureAppConfigurationEmulator/Extensions/HostingExtensions.cs index fe921e9..c9b322f 100644 --- a/src/AzureAppConfigurationEmulator/Extensions/HostingExtensions.cs +++ b/src/AzureAppConfigurationEmulator/Extensions/HostingExtensions.cs @@ -1,6 +1,5 @@ using System.Security.Cryptography.X509Certificates; -using AzureAppConfigurationEmulator.Contexts; -using Microsoft.EntityFrameworkCore; +using AzureAppConfigurationEmulator.Factories; namespace AzureAppConfigurationEmulator.Extensions; @@ -28,17 +27,6 @@ public static class HostingExtensions _ => throw new ArgumentOutOfRangeException() }; - public static string DatabasePath { get; } = Environment.OSVersion.Platform switch - { - PlatformID.Win32S => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", - PlatformID.Win32Windows => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", - PlatformID.Win32NT => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", - PlatformID.WinCE => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", - PlatformID.Unix => "/var/lib/azureappconfigurationemulator/emulator.db", - PlatformID.MacOSX => "/var/lib/azureappconfigurationemulator/emulator.db", - _ => throw new ArgumentOutOfRangeException() - }; - public static IWebHostBuilder ConfigureKestrel(this IWebHostBuilder builder) { return builder.ConfigureKestrel(options => @@ -55,19 +43,92 @@ public static IWebHostBuilder ConfigureKestrel(this IWebHostBuilder builder) public static void InitializeDatabase(this IApplicationBuilder app) { - if (!Directory.Exists(Path.GetDirectoryName(DatabasePath)!)) - { - Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!); - } + using var scope = app.ApplicationServices.GetRequiredService().CreateScope(); + var commandFactory = scope.ServiceProvider.GetRequiredService(); + var connectionFactory = scope.ServiceProvider.GetRequiredService(); - if (!File.Exists(DatabasePath)) + using var connection = connectionFactory.Create(); + + if (!Directory.Exists(Path.GetDirectoryName(connection.DataSource)!)) { - File.Create(DatabasePath); + Directory.CreateDirectory(Path.GetDirectoryName(connection.DataSource)!); } - using (var scope = app.ApplicationServices.GetRequiredService().CreateScope()) + if (!File.Exists(connection.DataSource)) { - scope.ServiceProvider.GetRequiredService().Database.Migrate(); + File.Create(connection.DataSource); } + + connection.Open(); + + using var command = commandFactory.Create(connection); + + command.CommandText = """ + CREATE TABLE IF NOT EXISTS configuration_settings ( + etag TEXT NOT NULL, + key TEXT NOT NULL, + label TEXT, + content_type TEXT, + value TEXT, + last_modified TEXT NOT NULL, + locked INTEGER NOT NULL, + tags TEXT, + PRIMARY KEY (key, label) + ); + + CREATE TABLE IF NOT EXISTS configuration_settings_history ( + etag TEXT NOT NULL, + key TEXT NOT NULL, + label TEXT, + content_type TEXT, + value TEXT, + last_modified TEXT NOT NULL, + locked INTEGER NOT NULL, + tags TEXT, + valid_from TEXT NOT NULL, + valid_to TEXT NOT NULL + ); + + CREATE TRIGGER IF NOT EXISTS delete_configuration_setting + AFTER DELETE ON configuration_settings + FOR EACH ROW + BEGIN + UPDATE configuration_settings_history + SET valid_to = datetime() + WHERE valid_to = '9999-12-31 23:59:59' + AND key = old.key + AND CASE old.label + WHEN NOT NULL THEN label = old.label + ELSE label IS NULL + END; + END; + + CREATE TRIGGER IF NOT EXISTS insert_configuration_setting + AFTER INSERT ON configuration_settings + FOR EACH ROW + BEGIN + INSERT INTO configuration_settings_history (etag, key, label, content_type, value, last_modified, locked, tags, valid_from, valid_to) + VALUES (new.etag, new.key, new.label, new.content_type, new.value, new.last_modified, new.locked, new.tags, new.last_modified, '9999-12-31 23:59:59'); + END; + + CREATE TRIGGER IF NOT EXISTS update_configuration_setting + AFTER UPDATE ON configuration_settings + FOR EACH ROW + BEGIN + UPDATE configuration_settings_history + SET valid_to = new.last_modified + WHERE valid_to = '9999-12-31 23:59:59' + AND key = old.key + AND CASE old.label + WHEN NOT NULL THEN label = old.label + ELSE label IS NULL + END; + + INSERT INTO configuration_settings_history (etag, key, label, content_type, value, last_modified, locked, tags, valid_from, valid_to) + VALUES (new.etag, new.key, new.label, new.content_type, new.value, new.last_modified, new.locked, new.tags, new.last_modified, '9999-12-31 23:59:59'); + END; + """; + + command.ExecuteNonQuery(); } } diff --git a/src/AzureAppConfigurationEmulator/Extensions/StringExtensions.cs b/src/AzureAppConfigurationEmulator/Extensions/StringExtensions.cs index 6a265d1..e869d28 100644 --- a/src/AzureAppConfigurationEmulator/Extensions/StringExtensions.cs +++ b/src/AzureAppConfigurationEmulator/Extensions/StringExtensions.cs @@ -1,22 +1,16 @@ using System.Text; -using AzureAppConfigurationEmulator.Constants; namespace AzureAppConfigurationEmulator.Extensions; public static class StringExtensions { - public static string? NormalizeNull(this string s) - { - return s is LabelFilter.Null ? null : s; - } - public static string Unescape(this string s) { var builder = new StringBuilder(); for (int i = 0; i < s.Length; i++) { - if (s[i] == '\\' && i < s.Length - 1) + if (s[i] is '\\' && i < s.Length - 1) { i++; } diff --git a/src/AzureAppConfigurationEmulator/Factories/DbCommandFactory.cs b/src/AzureAppConfigurationEmulator/Factories/DbCommandFactory.cs new file mode 100644 index 0000000..df70e96 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/DbCommandFactory.cs @@ -0,0 +1,56 @@ +using System.Collections; +using System.Data.Common; + +namespace AzureAppConfigurationEmulator.Factories; + +public class DbCommandFactory(ILogger? logger = null) : IDbCommandFactory +{ + private ILogger? Logger { get; } = logger; + + public DbCommand Create(DbConnection connection, string? text = null, IEnumerable? parameters = null) + { + Logger?.LogDebug("Creating the command."); + var command = connection.CreateCommand(); + + if (text is not null) + { + Logger?.LogDebug("Setting the command text."); + command.CommandText = text; + } + + if (parameters is not null) + { + Logger?.LogDebug("Enumerating the parameters."); + foreach (var parameter in parameters) + { + using (Logger?.BeginScope(new DbParameterLogScope(parameter))) + { + Logger?.LogDebug("Adding the parameter."); + command.Parameters.Add(parameter); + } + } + } + + return command; + } + + private class DbParameterLogScope(DbParameter parameter) : IEnumerable> + { + public IEnumerator> GetEnumerator() + { + yield return new KeyValuePair("ParameterName", parameter.ParameterName); + + yield return new KeyValuePair("ParameterValue", parameter.Value); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return $"ParameterName:{parameter.ParameterName} ParameterValue:{parameter.Value}"; + } + } +} diff --git a/src/AzureAppConfigurationEmulator/Factories/DbConnectionFactory.cs b/src/AzureAppConfigurationEmulator/Factories/DbConnectionFactory.cs new file mode 100644 index 0000000..e013b12 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/DbConnectionFactory.cs @@ -0,0 +1,25 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; + +namespace AzureAppConfigurationEmulator.Factories; + +public class DbConnectionFactory(IConfiguration? configuration = null) : IDbConnectionFactory +{ + private string ConnectionString { get; } = configuration?.GetConnectionString("DefaultConnection") ?? $"Data Source={DatabasePath}"; + + private static string DatabasePath { get; } = Environment.OSVersion.Platform switch + { + PlatformID.Win32S => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", + PlatformID.Win32Windows => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", + PlatformID.Win32NT => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", + PlatformID.WinCE => @"C:\ProgramData\Azure App Configuration Emulator\emulator.db", + PlatformID.Unix => "/var/lib/azureappconfigurationemulator/emulator.db", + PlatformID.MacOSX => "/var/lib/azureappconfigurationemulator/emulator.db", + _ => throw new ArgumentOutOfRangeException() + }; + + public DbConnection Create() + { + return new SqliteConnection(ConnectionString); + } +} diff --git a/src/AzureAppConfigurationEmulator/Factories/DbParameterFactory.cs b/src/AzureAppConfigurationEmulator/Factories/DbParameterFactory.cs new file mode 100644 index 0000000..a1b0878 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/DbParameterFactory.cs @@ -0,0 +1,23 @@ +using System.Data.Common; +using System.Text.Json; +using Microsoft.Data.Sqlite; + +namespace AzureAppConfigurationEmulator.Factories; + +public class DbParameterFactory : IDbParameterFactory +{ + public DbParameter Create(string name, TValue? value) + { + return value switch + { + bool b => new SqliteParameter(name, SqliteType.Integer) { Value = b ? 1 : 0 }, + DateTime d => new SqliteParameter(name, SqliteType.Text) { Value = d.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") }, + DateTimeOffset d => new SqliteParameter(name, SqliteType.Text) { Value = d.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") }, + IDictionary d => new SqliteParameter(name, SqliteType.Text) { Value = JsonSerializer.Serialize(d) }, + int i => new SqliteParameter(name, SqliteType.Integer) { Value = i }, + null => new SqliteParameter(name, SqliteType.Text) { Value = DBNull.Value }, + string s => new SqliteParameter(name, SqliteType.Text) { Value = s }, + _ => new SqliteParameter(name, SqliteType.Text) { Value = value.ToString() } + }; + } +} diff --git a/src/AzureAppConfigurationEmulator/Factories/IDbCommandFactory.cs b/src/AzureAppConfigurationEmulator/Factories/IDbCommandFactory.cs new file mode 100644 index 0000000..50de089 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/IDbCommandFactory.cs @@ -0,0 +1,8 @@ +using System.Data.Common; + +namespace AzureAppConfigurationEmulator.Factories; + +public interface IDbCommandFactory +{ + public DbCommand Create(DbConnection connection, string? text = null, IEnumerable? parameters = null); +} diff --git a/src/AzureAppConfigurationEmulator/Factories/IDbConnectionFactory.cs b/src/AzureAppConfigurationEmulator/Factories/IDbConnectionFactory.cs new file mode 100644 index 0000000..bea8e33 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/IDbConnectionFactory.cs @@ -0,0 +1,8 @@ +using System.Data.Common; + +namespace AzureAppConfigurationEmulator.Factories; + +public interface IDbConnectionFactory +{ + public DbConnection Create(); +} diff --git a/src/AzureAppConfigurationEmulator/Factories/IDbParameterFactory.cs b/src/AzureAppConfigurationEmulator/Factories/IDbParameterFactory.cs new file mode 100644 index 0000000..35d9a30 --- /dev/null +++ b/src/AzureAppConfigurationEmulator/Factories/IDbParameterFactory.cs @@ -0,0 +1,8 @@ +using System.Data.Common; + +namespace AzureAppConfigurationEmulator.Factories; + +public interface IDbParameterFactory +{ + public DbParameter Create(string name, TValue? value); +} diff --git a/src/AzureAppConfigurationEmulator/Handlers/KeyHandler.cs b/src/AzureAppConfigurationEmulator/Handlers/KeyHandler.cs index 8a10560..04d1b18 100644 --- a/src/AzureAppConfigurationEmulator/Handlers/KeyHandler.cs +++ b/src/AzureAppConfigurationEmulator/Handlers/KeyHandler.cs @@ -27,7 +27,7 @@ public static async Task setting.Key) .Distinct() .ToListAsync(cancellationToken); diff --git a/src/AzureAppConfigurationEmulator/Handlers/KeyValueHandler.cs b/src/AzureAppConfigurationEmulator/Handlers/KeyValueHandler.cs index 9901840..79f1e4a 100644 --- a/src/AzureAppConfigurationEmulator/Handlers/KeyValueHandler.cs +++ b/src/AzureAppConfigurationEmulator/Handlers/KeyValueHandler.cs @@ -24,7 +24,7 @@ public static async Task? Tags); } diff --git a/src/AzureAppConfigurationEmulator/Handlers/LabelHandler.cs b/src/AzureAppConfigurationEmulator/Handlers/LabelHandler.cs index 148ebf5..5cb1253 100644 --- a/src/AzureAppConfigurationEmulator/Handlers/LabelHandler.cs +++ b/src/AzureAppConfigurationEmulator/Handlers/LabelHandler.cs @@ -1,6 +1,5 @@ using System.Text.RegularExpressions; using AzureAppConfigurationEmulator.Constants; -using AzureAppConfigurationEmulator.Extensions; using AzureAppConfigurationEmulator.Repositories; using AzureAppConfigurationEmulator.Results; using Microsoft.AspNetCore.Http.HttpResults; @@ -28,8 +27,8 @@ public static async Task setting.Label.NormalizeNull()) + var labels = await repository.Get(label: name, cancellationToken: cancellationToken) + .Select(setting => setting.Label) .Distinct() .ToListAsync(cancellationToken); diff --git a/src/AzureAppConfigurationEmulator/Handlers/LockHandler.cs b/src/AzureAppConfigurationEmulator/Handlers/LockHandler.cs index d989fd7..911f0d9 100644 --- a/src/AzureAppConfigurationEmulator/Handlers/LockHandler.cs +++ b/src/AzureAppConfigurationEmulator/Handlers/LockHandler.cs @@ -26,17 +26,17 @@ public static async Task -using System; -using AzureAppConfigurationEmulator.Contexts; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace AzureAppConfigurationEmulator.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20231001000000_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); - - modelBuilder.Entity("AzureAppConfigurationEmulator.Entities.ConfigurationSetting", b => - { - b.Property("Key") - .HasColumnType("TEXT"); - - b.Property("Label") - .HasColumnType("TEXT"); - - b.Property("ContentType") - .HasColumnType("TEXT"); - - b.Property("ETag") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("IsReadOnly") - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("Key", "Label"); - - b.ToTable("ConfigurationSettings"); - }); - - modelBuilder.Entity("AzureAppConfigurationEmulator.Entities.ConfigurationSettingRevision", b => - { - b.Property("Key") - .HasColumnType("TEXT"); - - b.Property("Label") - .HasColumnType("TEXT"); - - b.Property("ValidFrom") - .HasColumnType("TEXT"); - - b.Property("ContentType") - .HasColumnType("TEXT"); - - b.Property("ETag") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("IsReadOnly") - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("ValidTo") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("Key", "Label", "ValidFrom"); - - b.ToTable("ConfigurationSettingRevisions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/AzureAppConfigurationEmulator/Migrations/20231001000000_InitialCreate.cs b/src/AzureAppConfigurationEmulator/Migrations/20231001000000_InitialCreate.cs deleted file mode 100644 index 2e984a0..0000000 --- a/src/AzureAppConfigurationEmulator/Migrations/20231001000000_InitialCreate.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace AzureAppConfigurationEmulator.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ConfigurationSettingRevisions", - columns: table => new - { - Key = table.Column(type: "TEXT", nullable: false), - Label = table.Column(type: "TEXT", nullable: false), - ETag = table.Column(type: "TEXT", nullable: false), - ContentType = table.Column(type: "TEXT", nullable: true), - Value = table.Column(type: "TEXT", nullable: true), - LastModified = table.Column(type: "TEXT", nullable: false), - IsReadOnly = table.Column(type: "INTEGER", nullable: false), - ValidFrom = table.Column(type: "TEXT", nullable: false), - ValidTo = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ConfigurationSettingRevisions", x => new { x.Key, x.Label, x.ValidFrom }); - }); - - migrationBuilder.CreateTable( - name: "ConfigurationSettings", - columns: table => new - { - Key = table.Column(type: "TEXT", nullable: false), - Label = table.Column(type: "TEXT", nullable: false), - ETag = table.Column(type: "TEXT", nullable: false), - ContentType = table.Column(type: "TEXT", nullable: true), - Value = table.Column(type: "TEXT", nullable: true), - LastModified = table.Column(type: "TEXT", nullable: false), - IsReadOnly = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ConfigurationSettings", x => new { x.Key, x.Label }); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ConfigurationSettingRevisions"); - - migrationBuilder.DropTable( - name: "ConfigurationSettings"); - } - } -} diff --git a/src/AzureAppConfigurationEmulator/Migrations/ApplicationDbContextModelSnapshot.cs b/src/AzureAppConfigurationEmulator/Migrations/ApplicationDbContextModelSnapshot.cs deleted file mode 100644 index 842ab0d..0000000 --- a/src/AzureAppConfigurationEmulator/Migrations/ApplicationDbContextModelSnapshot.cs +++ /dev/null @@ -1,86 +0,0 @@ -// -using System; -using AzureAppConfigurationEmulator.Contexts; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace AzureAppConfigurationEmulator.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - partial class ApplicationDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); - - modelBuilder.Entity("AzureAppConfigurationEmulator.Entities.ConfigurationSetting", b => - { - b.Property("Key") - .HasColumnType("TEXT"); - - b.Property("Label") - .HasColumnType("TEXT"); - - b.Property("ContentType") - .HasColumnType("TEXT"); - - b.Property("ETag") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("IsReadOnly") - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("Key", "Label"); - - b.ToTable("ConfigurationSettings"); - }); - - modelBuilder.Entity("AzureAppConfigurationEmulator.Entities.ConfigurationSettingRevision", b => - { - b.Property("Key") - .HasColumnType("TEXT"); - - b.Property("Label") - .HasColumnType("TEXT"); - - b.Property("ValidFrom") - .HasColumnType("TEXT"); - - b.Property("ContentType") - .HasColumnType("TEXT"); - - b.Property("ETag") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("IsReadOnly") - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("ValidTo") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("Key", "Label", "ValidFrom"); - - b.ToTable("ConfigurationSettingRevisions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/AzureAppConfigurationEmulator/Program.cs b/src/AzureAppConfigurationEmulator/Program.cs index c3984ba..b860ac2 100644 --- a/src/AzureAppConfigurationEmulator/Program.cs +++ b/src/AzureAppConfigurationEmulator/Program.cs @@ -1,10 +1,9 @@ using System.Text.Json; using AzureAppConfigurationEmulator.Authentication; -using AzureAppConfigurationEmulator.Contexts; using AzureAppConfigurationEmulator.Extensions; +using AzureAppConfigurationEmulator.Factories; using AzureAppConfigurationEmulator.Handlers; using AzureAppConfigurationEmulator.Repositories; -using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -12,12 +11,10 @@ builder.Services.AddAuthorization(); -builder.Services.AddDbContext(builder => -{ - builder.UseSqlite($"Data Source={HostingExtensions.DatabasePath}"); -}); - -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.ConfigureHttpJsonOptions(options => { diff --git a/src/AzureAppConfigurationEmulator/Repositories/ConfigurationSettingRepository.cs b/src/AzureAppConfigurationEmulator/Repositories/ConfigurationSettingRepository.cs index 8c81e8a..aacaa60 100644 --- a/src/AzureAppConfigurationEmulator/Repositories/ConfigurationSettingRepository.cs +++ b/src/AzureAppConfigurationEmulator/Repositories/ConfigurationSettingRepository.cs @@ -1,122 +1,235 @@ +using System.Data.Common; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.RegularExpressions; using AzureAppConfigurationEmulator.Constants; -using AzureAppConfigurationEmulator.Contexts; using AzureAppConfigurationEmulator.Entities; using AzureAppConfigurationEmulator.Extensions; -using LinqKit; -using Microsoft.EntityFrameworkCore; +using AzureAppConfigurationEmulator.Factories; namespace AzureAppConfigurationEmulator.Repositories; -public class ConfigurationSettingRepository(ApplicationDbContext context) : IConfigurationSettingRepository +public partial class ConfigurationSettingRepository( + IDbCommandFactory commandFactory, + IDbConnectionFactory connectionFactory, + ILogger logger, + IDbParameterFactory parameterFactory) : IConfigurationSettingRepository { - private ApplicationDbContext Context { get; } = context; + private IDbCommandFactory CommandFactory { get; } = commandFactory; - public async Task AddAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default) - { - Context.ConfigurationSettings.Add(setting); + private IDbConnectionFactory ConnectionFactory { get; } = connectionFactory; + + private ILogger Logger { get; } = logger; + + private IDbParameterFactory ParameterFactory { get; } = parameterFactory; - Context.ConfigurationSettingRevisions.Add(new ConfigurationSettingRevision(setting)); + public async Task AddAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default) + { + const string text = "INSERT INTO configuration_settings (etag, key, label, content_type, value, last_modified, locked, tags) VALUES ($etag, $key, $label, $content_type, $value, $last_modified, $locked, $tags)"; - await Context.SaveChangesAsync(cancellationToken); + var parameters = new List + { + ParameterFactory.Create("$etag", setting.Etag), + ParameterFactory.Create("$key", setting.Key), + ParameterFactory.Create("$label", setting.Label), + ParameterFactory.Create("$content_type", setting.ContentType), + ParameterFactory.Create("$value", setting.Value), + ParameterFactory.Create("$last_modified", setting.LastModified), + ParameterFactory.Create("$locked", setting.Locked), + ParameterFactory.Create("$tags", setting.Tags) + }; + + await ExecuteNonQueryAsync(text, parameters, cancellationToken); } - public IAsyncEnumerable Get(string key = KeyFilter.Any, string label = LabelFilter.Any) + public async IAsyncEnumerable Get( + string key = KeyFilter.Any, + string label = LabelFilter.Any, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var outer = PredicateBuilder.New(true); + var text = "SELECT etag, key, label, content_type, value, last_modified, locked, tags FROM configuration_settings"; + + var parameters = new List(); + + var outers = new List(); - if (key != KeyFilter.Any) + if (key is not KeyFilter.Any) { - var inner = PredicateBuilder.New(false); + var keys = UnescapedCommaRegex().Split(key).Select(s => s.Unescape()).ToList(); - foreach (var s in new Regex(@"(? s.Unescape())) + var inners = new List(); + + for (var i = 0; i < keys.Count; i++) { - var match = new Regex(@"^(.*)(? setting.Key.StartsWith(match.Groups[1].Value)); - } - else - { - inner = inner.Or(setting => setting.Key == s); - } + parameters.Add(ParameterFactory.Create($"$key{i}", match.Success ? $"{match.Groups[1].Value}%" : keys[i])); + + inners.Add(match.Success ? $"key LIKE $key{i}" : $"key = $key{i}"); } - outer = outer.And(inner); + outers.Add($"({string.Join(" OR ", inners)})"); } - if (label != LabelFilter.Any) + if (label is not LabelFilter.Any) { - var inner = PredicateBuilder.New(false); + var labels = UnescapedCommaRegex().Split(label).Select(s => s.Unescape()).ToList(); - foreach (var s in new Regex(@"(? s.Unescape())) - { - var match = new Regex(@"^(.*)(?(); - if (match.Success) + for (var i = 0; i < labels.Count; i++) + { + if (labels[i] == LabelFilter.Null) { - inner = inner.Or(setting => setting.Label.StartsWith(match.Groups[1].Value)); + inners.Add("label IS NULL"); } else { - inner = inner.Or(setting => setting.Label == s); + var match = TrailingWildcardRegex().Match(labels[i]); + + parameters.Add(ParameterFactory.Create($"$label{i}", match.Success ? $"{match.Groups[1].Value}%" : labels[i])); + + inners.Add(match.Success ? $"label LIKE $label{i}" : $"label = $label{i}"); } } - outer = outer.And(inner); + outers.Add($"({string.Join(" OR ", inners)})"); + } + + if (outers.Count > 0) + { + text += $" WHERE {string.Join(" AND ", outers)}"; } - return Context.ConfigurationSettings.Where(outer).AsAsyncEnumerable(); + await foreach (var reader in ExecuteReader(text, parameters, cancellationToken)) + { + yield return new ConfigurationSetting( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + DateTimeOffset.Parse(reader.GetString(5), styles: DateTimeStyles.AssumeUniversal), + reader.GetBoolean(6), + reader.IsDBNull(7) ? null : JsonSerializer.Deserialize>(reader.GetString(7))); + } } - public async Task RemoveAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default) + public async Task RemoveAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default) { - var date = DateTimeOffset.UtcNow; + var text = "DELETE FROM configuration_settings"; + + var parameters = new List { ParameterFactory.Create("$key", setting.Key) }; - Context.ConfigurationSettings.Remove(setting); + var outers = new List { "key = $key" }; - var revision = await Context.ConfigurationSettingRevisions.SingleOrDefaultAsync( - revision => - revision.Key == setting.Key && - revision.Label == setting.Label && - revision.ValidFrom.CompareTo(date) <= 0 && - revision.ValidTo == null, - cancellationToken); + if (setting.Label is not null) + { + parameters.Add(ParameterFactory.Create("$label", setting.Label)); - if (revision != null) + outers.Add("label = $label"); + } + else { - revision.ValidTo = date; + outers.Add("label IS NULL"); + } - Context.ConfigurationSettingRevisions.Update(revision); + if (outers.Count > 0) + { + text += $" WHERE {string.Join(" AND ", outers)}"; } - await Context.SaveChangesAsync(cancellationToken); + await ExecuteNonQueryAsync(text, parameters, cancellationToken); } - public async Task UpdateAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default) + public async Task UpdateAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default) { - var date = DateTimeOffset.UtcNow; + var text = "UPDATE configuration_settings SET etag = $etag, content_type = $content_type, value = $value, last_modified = $last_modified, locked = $locked, tags = $tags"; + + var parameters = new List + { + ParameterFactory.Create("$etag", setting.Etag), + ParameterFactory.Create("$key", setting.Key), + ParameterFactory.Create("$content_type", setting.ContentType), + ParameterFactory.Create("$value", setting.Value), + ParameterFactory.Create("$last_modified", setting.LastModified), + ParameterFactory.Create("$locked", setting.Locked), + ParameterFactory.Create("$tags", setting.Tags) + }; + + var outers = new List { "key = $key" }; + + if (setting.Label is not null) + { + parameters.Add(ParameterFactory.Create("$label", setting.Label)); + + outers.Add("label = $label"); + } + else + { + outers.Add("label IS NULL"); + } + + if (outers.Count > 0) + { + text += $" WHERE {string.Join(" AND ", outers)}"; + } + + await ExecuteNonQueryAsync(text, parameters, cancellationToken); + } - Context.ConfigurationSettings.Update(setting); + [GeneratedRegex(@"^(.*)(? - revision.Key == setting.Key && - revision.Label == setting.Label && - revision.ValidFrom.CompareTo(date) <= 0 && - revision.ValidTo == null, - cancellationToken); + [GeneratedRegex(@"(? parameters, + CancellationToken cancellationToken = default) + { + using (Logger.BeginScope(new Dictionary { { "CommandText", text } })) { - revision.ValidTo = setting.LastModified; + Logger.LogDebug("Creating the connection."); + await using var connection = ConnectionFactory.Create(); + await connection.OpenAsync(cancellationToken); + + Logger.LogDebug("Creating the command."); + await using var command = CommandFactory.Create(connection, text, parameters); - Context.ConfigurationSettingRevisions.Update(revision); + Logger.LogDebug("Executing the command."); + await command.ExecuteNonQueryAsync(cancellationToken); } + } - Context.ConfigurationSettingRevisions.Add(new ConfigurationSettingRevision(setting)); + private async IAsyncEnumerable ExecuteReader( + string text, + IEnumerable parameters, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using (Logger.BeginScope(new Dictionary { { "CommandText", text } })) + { + Logger.LogDebug("Creating the connection."); + await using var connection = ConnectionFactory.Create(); + await connection.OpenAsync(cancellationToken); + + Logger.LogDebug("Creating the command."); + await using var command = CommandFactory.Create(connection, text, parameters); - await Context.SaveChangesAsync(cancellationToken); + Logger.LogDebug("Executing the command."); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + yield return reader; + } + } } } diff --git a/src/AzureAppConfigurationEmulator/Repositories/IConfigurationSettingRepository.cs b/src/AzureAppConfigurationEmulator/Repositories/IConfigurationSettingRepository.cs index daa236d..9e4f000 100644 --- a/src/AzureAppConfigurationEmulator/Repositories/IConfigurationSettingRepository.cs +++ b/src/AzureAppConfigurationEmulator/Repositories/IConfigurationSettingRepository.cs @@ -5,11 +5,20 @@ namespace AzureAppConfigurationEmulator.Repositories; public interface IConfigurationSettingRepository { - public Task AddAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default); + public Task AddAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default); - public IAsyncEnumerable Get(string key = KeyFilter.Any, string label = LabelFilter.Any); + public IAsyncEnumerable Get( + string key = KeyFilter.Any, + string label = LabelFilter.Any, + CancellationToken cancellationToken = default); - public Task RemoveAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default); + public Task RemoveAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default); - public Task UpdateAsync(ConfigurationSetting setting, CancellationToken cancellationToken = default); + public Task UpdateAsync( + ConfigurationSetting setting, + CancellationToken cancellationToken = default); } diff --git a/src/AzureAppConfigurationEmulator/Results/KeySetResult.cs b/src/AzureAppConfigurationEmulator/Results/KeySetResult.cs index c8220bd..34a278b 100644 --- a/src/AzureAppConfigurationEmulator/Results/KeySetResult.cs +++ b/src/AzureAppConfigurationEmulator/Results/KeySetResult.cs @@ -14,19 +14,16 @@ public async Task ExecuteAsync(HttpContext httpContext) httpContext.Response.StatusCode = StatusCode.Value; } - if (Value is not null) - { - await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); - } + await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); } public string? ContentType => "application/vnd.microsoft.appconfig.keyset+json"; public int? StatusCode => StatusCodes.Status200OK; - object? IValueHttpResult.Value => Value; + object IValueHttpResult.Value => Value; - public KeySet? Value { get; } = new(keys.Select(key => new Key(key))); + public KeySet Value { get; } = new(keys.Select(key => new Key(key))); } public record KeySet(IEnumerable Items); diff --git a/src/AzureAppConfigurationEmulator/Results/KeyValueResult.cs b/src/AzureAppConfigurationEmulator/Results/KeyValueResult.cs index a64c66a..84d6de2 100644 --- a/src/AzureAppConfigurationEmulator/Results/KeyValueResult.cs +++ b/src/AzureAppConfigurationEmulator/Results/KeyValueResult.cs @@ -1,5 +1,4 @@ using AzureAppConfigurationEmulator.Entities; -using AzureAppConfigurationEmulator.Extensions; namespace AzureAppConfigurationEmulator.Results; @@ -8,45 +7,26 @@ public class KeyValueResult(ConfigurationSetting setting) : IContentTypeHttpResult, IStatusCodeHttpResult, IValueHttpResult, - IValueHttpResult + IValueHttpResult { public async Task ExecuteAsync(HttpContext httpContext) { - httpContext.Response.Headers.ETag = setting.ETag; - httpContext.Response.Headers.LastModified = setting.LastModified.ToString("R"); + httpContext.Response.Headers.ETag = Value.Etag; + httpContext.Response.Headers.LastModified = Value.LastModified.ToString("R"); if (StatusCode.HasValue) { httpContext.Response.StatusCode = StatusCode.Value; } - if (Value is not null) - { - await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); - } + await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); } public string? ContentType => "application/vnd.microsoft.appconfig.kv+json"; public int? StatusCode => StatusCodes.Status200OK; - object? IValueHttpResult.Value => Value; + object IValueHttpResult.Value => Value; - public KeyValue? Value { get; } = new( - setting.ETag, - setting.Key, - setting.Label.NormalizeNull(), - setting.ContentType, - setting.Value, - setting.LastModified, - setting.IsReadOnly); + public ConfigurationSetting Value { get; } = setting; } - -public record KeyValue( - string Etag, - string Key, - string? Label, - string? ContentType, - string? Value, - DateTimeOffset LastModified, - bool Locked); diff --git a/src/AzureAppConfigurationEmulator/Results/KeyValueSetResult.cs b/src/AzureAppConfigurationEmulator/Results/KeyValueSetResult.cs index 6f9c522..af3ccb5 100644 --- a/src/AzureAppConfigurationEmulator/Results/KeyValueSetResult.cs +++ b/src/AzureAppConfigurationEmulator/Results/KeyValueSetResult.cs @@ -1,5 +1,4 @@ using AzureAppConfigurationEmulator.Entities; -using AzureAppConfigurationEmulator.Extensions; namespace AzureAppConfigurationEmulator.Results; @@ -17,28 +16,16 @@ public async Task ExecuteAsync(HttpContext httpContext) httpContext.Response.StatusCode = StatusCode.Value; } - if (Value is not null) - { - await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); - } + await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); } public string? ContentType => "application/vnd.microsoft.appconfig.kvset+json"; public int? StatusCode => StatusCodes.Status200OK; - object? IValueHttpResult.Value => Value; - - public KeyValueSet? Value { get; } = new( - settings.Select( - setting => new KeyValue( - setting.ETag, - setting.Key, - setting.Label.NormalizeNull(), - setting.ContentType, - setting.Value, - setting.LastModified, - setting.IsReadOnly))); + object IValueHttpResult.Value => Value; + + public KeyValueSet Value { get; } = new(settings); } -public record KeyValueSet(IEnumerable Items); +public record KeyValueSet(IEnumerable Items); diff --git a/src/AzureAppConfigurationEmulator/Results/LabelSetResult.cs b/src/AzureAppConfigurationEmulator/Results/LabelSetResult.cs index eef8e03..df00599 100644 --- a/src/AzureAppConfigurationEmulator/Results/LabelSetResult.cs +++ b/src/AzureAppConfigurationEmulator/Results/LabelSetResult.cs @@ -14,19 +14,16 @@ public async Task ExecuteAsync(HttpContext httpContext) httpContext.Response.StatusCode = StatusCode.Value; } - if (Value is not null) - { - await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); - } + await httpContext.Response.WriteAsJsonAsync(Value, options: default, ContentType); } public string? ContentType => "application/vnd.microsoft.appconfig.labelset+json"; public int? StatusCode => StatusCodes.Status200OK; - object? IValueHttpResult.Value => Value; + object IValueHttpResult.Value => Value; - public LabelSet? Value { get; } = new(labels.Select(label => new Label(label))); + public LabelSet Value { get; } = new(labels.Select(label => new Label(label))); } public record LabelSet(IEnumerable