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