aboutsummaryrefslogtreecommitdiff
path: root/dropped/docker
diff options
context:
space:
mode:
Diffstat (limited to 'dropped/docker')
-rw-r--r--dropped/docker/crupest-api/CrupestApi/.dockerignore2
-rw-r--r--dropped/docker/crupest-api/CrupestApi/.gitignore4
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/ColumnTypeInfoTest.cs39
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudIntegratedTest.cs200
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs77
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/SqlCompareHelper.cs85
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs35
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs23
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/CrupestApi.Commons.Tests.csproj29
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Usings.cs1
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Config.cs23
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs236
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs188
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnTypeInfo.cs218
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs132
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudServiceCollectionExtensions.cs34
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs101
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs75
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbNullValue.cs9
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs206
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/IClause.cs24
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/InsertClause.cs77
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/DatabaseMigrator.cs44
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/SqliteDatabaseMigrator.cs175
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/OrderByClause.cs50
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ParamMap.cs73
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/README.md47
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs628
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UpdateClause.cs77
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UserException.cs15
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/WhereClause.cs182
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/CrupestApi.Commons.csproj16
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs8
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpContextExtensions.cs113
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/ISecretService.cs8
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretInfo.cs48
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs48
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretServiceCollectionExtensions.cs12
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretsConstants.cs6
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/CrupestApi.Files.csproj20
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/FilesService.cs6
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/CrupestApi.Secrets.csproj20
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsExtensions.cs19
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/CrupestApi.Todos.csproj15
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosConfiguration.cs14
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosService.cs163
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosServiceCollectionExtensions.cs21
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosWebApplicationExtensions.cs32
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi.sln46
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi/CrupestApi.csproj17
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi/Program.cs24
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi/Properties/launchSettings.json15
-rw-r--r--dropped/docker/crupest-api/CrupestApi/CrupestApi/appsettings.json8
-rw-r--r--dropped/docker/crupest-api/Dockerfile13
54 files changed, 3801 insertions, 0 deletions
diff --git a/dropped/docker/crupest-api/CrupestApi/.dockerignore b/dropped/docker/crupest-api/CrupestApi/.dockerignore
new file mode 100644
index 0000000..f1c182d
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/.dockerignore
@@ -0,0 +1,2 @@
+*/obj
+*/bin
diff --git a/dropped/docker/crupest-api/CrupestApi/.gitignore b/dropped/docker/crupest-api/CrupestApi/.gitignore
new file mode 100644
index 0000000..371ea59
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/.gitignore
@@ -0,0 +1,4 @@
+.vs
+obj
+bin
+dev-config.json
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/ColumnTypeInfoTest.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/ColumnTypeInfoTest.cs
new file mode 100644
index 0000000..b9ec03e
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/ColumnTypeInfoTest.cs
@@ -0,0 +1,39 @@
+using System.Data;
+
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class ColumnTypeInfoTest
+{
+ private ColumnTypeProvider _provider = new ColumnTypeProvider();
+
+ [Theory]
+ [InlineData(typeof(int), DbType.Int32, 123)]
+ [InlineData(typeof(long), DbType.Int64, 456)]
+ [InlineData(typeof(sbyte), DbType.SByte, 789)]
+ [InlineData(typeof(short), DbType.Int16, 101)]
+ [InlineData(typeof(float), DbType.Single, 1.0f)]
+ [InlineData(typeof(double), DbType.Double, 1.0)]
+ [InlineData(typeof(string), DbType.String, "Hello world!")]
+ [InlineData(typeof(byte[]), DbType.Binary, new byte[] { 1, 2, 3 })]
+ public void BasicColumnTypeTest(Type type, DbType dbType, object? value)
+ {
+ var typeInfo = _provider.Get(type);
+ Assert.True(typeInfo.IsSimple);
+ Assert.Equal(dbType, typeInfo.DbType);
+ Assert.Equal(value, typeInfo.ConvertFromDatabase(value));
+ Assert.Equal(value, typeInfo.ConvertToDatabase(value));
+ }
+
+ [Fact]
+ public void DateTimeColumnTypeTest()
+ {
+ var dateTimeColumnTypeInfo = _provider.Get(typeof(DateTime));
+ Assert.Equal(typeof(DateTime), dateTimeColumnTypeInfo.ClrType);
+ Assert.Equal(typeof(string), dateTimeColumnTypeInfo.DatabaseClrType);
+
+ var dateTime = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var dateTimeString = "2000-01-01T00:00:00Z";
+ Assert.Equal(dateTimeString, dateTimeColumnTypeInfo.ConvertToDatabase(dateTime));
+ Assert.Equal(dateTime, dateTimeColumnTypeInfo.ConvertFromDatabase(dateTimeString));
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudIntegratedTest.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudIntegratedTest.cs
new file mode 100644
index 0000000..bd07c70
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudIntegratedTest.cs
@@ -0,0 +1,200 @@
+using System.Net;
+using System.Net.Http.Headers;
+using CrupestApi.Commons.Secrets;
+using Microsoft.AspNetCore.TestHost;
+
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class CrudIntegratedTest : IAsyncLifetime
+{
+ private readonly WebApplication _app;
+ private HttpClient _httpClient = default!;
+ private HttpClient _authorizedHttpClient = default!;
+ private string _token = default!;
+
+ public CrudIntegratedTest()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.Services.AddSingleton<IDbConnectionFactory, SqliteMemoryConnectionFactory>();
+ builder.Services.AddCrud<TestEntity>();
+ builder.WebHost.UseTestServer();
+ _app = builder.Build();
+ _app.UseCrudCore();
+ _app.MapCrud<TestEntity>("/test", "test-perm");
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _app.StartAsync();
+ _httpClient = _app.GetTestClient();
+
+ using (var scope = _app.Services.CreateScope())
+ {
+ var secretService = (SecretService)scope.ServiceProvider.GetRequiredService<ISecretService>();
+ var key = secretService.Create(new SecretInfo
+ {
+ Key = "test-perm"
+ });
+ _token = secretService.GetByKey(key).Secret;
+ }
+
+ _authorizedHttpClient = _app.GetTestClient();
+ _authorizedHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _app.StopAsync();
+ }
+
+
+ [Fact]
+ public async Task EmptyTest()
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<List<TestEntity>>();
+ Assert.NotNull(body);
+ Assert.Empty(body);
+ }
+
+ [Fact]
+ public async Task CrudTest()
+ {
+ {
+ using var response = await _authorizedHttpClient.PostAsJsonAsync("/test", new TestEntity
+ {
+ Name = "test",
+ Age = 22
+ });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<TestEntity>();
+ Assert.NotNull(body);
+ Assert.Equal("test", body.Name);
+ Assert.Equal(22, body.Age);
+ Assert.Null(body.Height);
+ Assert.NotEmpty(body.Secret);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<List<TestEntity>>();
+ Assert.NotNull(body);
+ var entity = Assert.Single(body);
+ Assert.Equal("test", entity.Name);
+ Assert.Equal(22, entity.Age);
+ Assert.Null(entity.Height);
+ Assert.NotEmpty(entity.Secret);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test/test");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<TestEntity>();
+ Assert.NotNull(body);
+ Assert.Equal("test", body.Name);
+ Assert.Equal(22, body.Age);
+ Assert.Null(body.Height);
+ Assert.NotEmpty(body.Secret);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.PatchAsJsonAsync("/test/test", new TestEntity
+ {
+ Name = "test-2",
+ Age = 23,
+ Height = 188.0f
+ });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<TestEntity>();
+ Assert.NotNull(body);
+ Assert.Equal("test-2", body.Name);
+ Assert.Equal(23, body.Age);
+ Assert.Equal(188.0f, body.Height);
+ Assert.NotEmpty(body.Secret);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test/test-2");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<TestEntity>();
+ Assert.NotNull(body);
+ Assert.Equal("test-2", body.Name);
+ Assert.Equal(23, body.Age);
+ Assert.Equal(188.0f, body.Height);
+ Assert.NotEmpty(body.Secret);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.DeleteAsync("/test/test-2");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync<List<TestEntity>>();
+ Assert.NotNull(body);
+ Assert.Empty(body);
+ }
+ }
+
+ [Fact]
+ public async Task UnauthorizedTest()
+ {
+ {
+ using var response = await _httpClient.GetAsync("/test");
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ {
+ using var response = await _httpClient.GetAsync("/test/test");
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ {
+ using var response = await _httpClient.PostAsJsonAsync("/test", new TestEntity
+ {
+ Name = "test",
+ Age = 22
+ });
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ {
+ using var response = await _httpClient.PatchAsJsonAsync("/test/test", new TestEntity
+ {
+ Name = "test-2",
+ Age = 23,
+ Height = 188.0f
+ });
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ {
+ using var response = await _httpClient.DeleteAsync("/test/test");
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+ }
+
+ [Fact]
+ public async Task NotFoundTest()
+ {
+ {
+ using var response = await _authorizedHttpClient.GetAsync("/test/test");
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ {
+ using var response = await _authorizedHttpClient.PatchAsJsonAsync("/test/test", new TestEntity
+ {
+ Name = "test-2",
+ Age = 23,
+ Height = 188.0f
+ });
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs
new file mode 100644
index 0000000..ad0d34c
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs
@@ -0,0 +1,77 @@
+using CrupestApi.Commons.Crud.Migrations;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class CrudServiceTest
+{
+ private readonly SqliteMemoryConnectionFactory _memoryConnectionFactory = new SqliteMemoryConnectionFactory();
+
+ private readonly CrudService<TestEntity> _crudService;
+
+ public CrudServiceTest()
+ {
+ var columnTypeProvider = new ColumnTypeProvider();
+ var tableInfoFactory = new TableInfoFactory(columnTypeProvider, NullLoggerFactory.Instance);
+ var dbConnectionFactory = new SqliteMemoryConnectionFactory();
+
+ _crudService = new CrudService<TestEntity>(
+ tableInfoFactory, dbConnectionFactory, new SqliteDatabaseMigrator(), NullLoggerFactory.Instance);
+ }
+
+ [Fact]
+ public void CrudTest()
+ {
+ var key = _crudService.Create(new TestEntity()
+ {
+ Name = "crupest",
+ Age = 18,
+ });
+
+ Assert.Equal("crupest", key);
+
+ var entity = _crudService.GetByKey(key);
+ Assert.Equal("crupest", entity.Name);
+ Assert.Equal(18, entity.Age);
+ Assert.Null(entity.Height);
+ Assert.NotEmpty(entity.Secret);
+
+ var list = _crudService.GetAll();
+ entity = Assert.Single(list);
+ Assert.Equal("crupest", entity.Name);
+ Assert.Equal(18, entity.Age);
+ Assert.Null(entity.Height);
+ Assert.NotEmpty(entity.Secret);
+
+ var count = _crudService.GetCount();
+ Assert.Equal(1, count);
+
+ _crudService.UpdateByKey(key, new TestEntity()
+ {
+ Name = "crupest2.0",
+ Age = 22,
+ Height = 180,
+ });
+
+ entity = _crudService.GetByKey("crupest2.0");
+ Assert.Equal("crupest2.0", entity.Name);
+ Assert.Equal(22, entity.Age);
+ Assert.Equal(180, entity.Height);
+ Assert.NotEmpty(entity.Secret);
+
+ _crudService.DeleteByKey("crupest2.0");
+
+ count = _crudService.GetCount();
+ Assert.Equal(0, count);
+ }
+
+ [Fact]
+ public void EntityNotExistTest()
+ {
+ Assert.Throws<EntityNotExistException>(() => _crudService.GetByKey("KeyNotExist"));
+ Assert.Throws<EntityNotExistException>(() => _crudService.UpdateByKey("KeyNotExist", new TestEntity
+ {
+ Name = "crupest"
+ }));
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/SqlCompareHelper.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/SqlCompareHelper.cs
new file mode 100644
index 0000000..72b6218
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/SqlCompareHelper.cs
@@ -0,0 +1,85 @@
+using System.Text;
+
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class SqlCompareHelper
+{
+ private static List<char> SymbolTokens = new List<char>() { '(', ')', ';' };
+
+ public static List<string> SqlExtractWords(string? sql, bool toLower = true)
+ {
+ var result = new List<string>();
+
+ if (string.IsNullOrEmpty(sql))
+ {
+ return result;
+ }
+
+ var current = 0;
+
+ StringBuilder? wordBuilder = null;
+
+ while (current < sql.Length)
+ {
+ if (char.IsWhiteSpace(sql[current]))
+ {
+ if (wordBuilder is not null)
+ {
+ result.Add(wordBuilder.ToString());
+ wordBuilder = null;
+ }
+ }
+ else if (SymbolTokens.Contains(sql[current]))
+ {
+ if (wordBuilder is not null)
+ {
+ result.Add(wordBuilder.ToString());
+ wordBuilder = null;
+ }
+ result.Add(sql[current].ToString());
+ }
+ else
+ {
+ if (wordBuilder is not null)
+ {
+ wordBuilder.Append(sql[current]);
+ }
+ else
+ {
+ wordBuilder = new StringBuilder();
+ wordBuilder.Append(sql[current]);
+ }
+ }
+ current++;
+ }
+
+ if (wordBuilder is not null)
+ {
+ result.Add(wordBuilder.ToString());
+ }
+
+ if (toLower)
+ {
+ for (int i = 0; i < result.Count; i++)
+ {
+ result[i] = result[i].ToLower();
+ }
+ }
+
+ return result;
+ }
+
+ public static bool SqlEqual(string left, string right)
+ {
+ return SqlExtractWords(left) == SqlExtractWords(right);
+ }
+
+ [Fact]
+ public void TestSqlExtractWords()
+ {
+ var sql = "SELECT * FROM TableName WHERE (id = @abcd);";
+ var words = SqlExtractWords(sql);
+
+ Assert.Equal(new List<string> { "select", "*", "from", "tablename", "where", "(", "id", "=", "@abcd", ")", ";" }, words);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs
new file mode 100644
index 0000000..b0aa702
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs
@@ -0,0 +1,35 @@
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class TableInfoTest
+{
+ private static TableInfoFactory TableInfoFactory = new TableInfoFactory(new ColumnTypeProvider(), NullLoggerFactory.Instance);
+
+ private TableInfo _tableInfo;
+
+ public TableInfoTest()
+ {
+ _tableInfo = TableInfoFactory.Get(typeof(TestEntity));
+ }
+
+ [Fact]
+ public void TestColumnCount()
+ {
+ Assert.Equal(5, _tableInfo.Columns.Count);
+ Assert.Equal(4, _tableInfo.PropertyColumns.Count);
+ Assert.Equal(4, _tableInfo.ColumnProperties.Count);
+ Assert.Equal(1, _tableInfo.NonColumnProperties.Count);
+ }
+
+ [Fact]
+ public void GenerateSelectSqlTest()
+ {
+ var (sql, parameters) = _tableInfo.GenerateSelectSql(null, WhereClause.Create().Eq("Name", "Hello"));
+ var parameterName = parameters.First().Name;
+
+ // TODO: Is there a way to auto detect parameters?
+ SqlCompareHelper.SqlEqual($"SELECT * FROM TestEntity WHERE (Name = @{parameterName})", sql);
+ Assert.Equal("Hello", parameters.Get<string>(parameterName));
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs
new file mode 100644
index 0000000..c15334c
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs
@@ -0,0 +1,23 @@
+namespace CrupestApi.Commons.Crud.Tests;
+
+public class TestEntity
+{
+ [Column(ActAsKey = true, NotNull = true)]
+ public string Name { get; set; } = default!;
+
+ [Column(NotNull = true)]
+ public int Age { get; set; }
+
+ [Column]
+ public float? Height { get; set; }
+
+ [Column(OnlyGenerated = true, NotNull = true, NoUpdate = true)]
+ public string Secret { get; set; } = default!;
+
+ public static string SecretDefaultValueGenerator()
+ {
+ return "secret";
+ }
+
+ public string NonColumn { get; set; } = "Not A Column";
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/CrupestApi.Commons.Tests.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/CrupestApi.Commons.Tests.csproj
new file mode 100644
index 0000000..0360ee1
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/CrupestApi.Commons.Tests.csproj
@@ -0,0 +1,29 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="7.0.1" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
+ <PackageReference Include="xunit" Version="2.4.2" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="coverlet.collector" Version="3.2.0">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\CrupestApi.Commons\CrupestApi.Commons.csproj" />
+ </ItemGroup>
+
+</Project> \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Usings.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Usings.cs
new file mode 100644
index 0000000..8c927eb
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit; \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Config.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Config.cs
new file mode 100644
index 0000000..0ca3547
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Config.cs
@@ -0,0 +1,23 @@
+namespace CrupestApi.Commons;
+
+public class CrupestApiConfig
+{
+ public string DataDir { get; set; } = string.Empty;
+}
+
+public static class CrupestApiConfigExtensions
+{
+ public static IServiceCollection AddCrupestApiConfig(this IServiceCollection services)
+ {
+ services.AddOptions<CrupestApiConfig>().BindConfiguration("CrupestApi");
+ services.PostConfigure<CrupestApiConfig>(config =>
+ {
+ if (config.DataDir is null || config.DataDir.Length == 0)
+ {
+ config.DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "crupest-api");
+ }
+ });
+
+ return services;
+ }
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs
new file mode 100644
index 0000000..e8d3c2e
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs
@@ -0,0 +1,236 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Text;
+
+namespace CrupestApi.Commons.Crud;
+
+public class ColumnInfo
+{
+ private readonly AggregateColumnMetadata _metadata = new AggregateColumnMetadata();
+ private ILogger<ColumnInfo> _logger;
+
+ /// <summary>
+ /// Initialize a column without corresponding property.
+ /// </summary>
+ public ColumnInfo(TableInfo table, IColumnMetadata metadata, Type clrType, IColumnTypeProvider typeProvider, ILoggerFactory loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger<ColumnInfo>();
+ if (metadata is null)
+ throw new ArgumentException("You must specify metadata for non-property column.");
+ if (metadata.TryGetValue(ColumnMetadataKeys.ColumnName, out var columnName))
+ _logger.LogInformation("Create column without property.", columnName);
+ else
+ throw new ArgumentException("You must specify name in metadata for non-property column.");
+
+ Table = table;
+ _metadata.Add(metadata);
+ ColumnType = typeProvider.Get(clrType);
+ }
+
+ /// <summary>
+ /// Initialize a column with corresponding property.
+ /// </summary>
+ public ColumnInfo(TableInfo table, PropertyInfo propertyInfo, IColumnTypeProvider typeProvider, ILoggerFactory loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger<ColumnInfo>();
+ _logger.LogInformation("Create column with property {}.", propertyInfo.Name);
+
+ Table = table;
+ PropertyInfo = propertyInfo;
+ ColumnType = typeProvider.Get(propertyInfo.PropertyType);
+
+ var columnAttribute = propertyInfo.GetCustomAttribute<ColumnAttribute>();
+ if (columnAttribute is not null)
+ {
+ _metadata.Add(columnAttribute);
+ }
+ }
+
+ public TableInfo Table { get; }
+
+ public Type EntityType => Table.EntityType;
+
+ // If null, there is no corresponding property.
+ public PropertyInfo? PropertyInfo { get; } = null;
+
+ public IColumnMetadata Metadata => _metadata;
+
+ public IColumnTypeInfo ColumnType { get; }
+
+ public bool IsPrimaryKey => Metadata.GetValueOrDefault(ColumnMetadataKeys.IsPrimaryKey) is true;
+ public bool IsAutoIncrement => IsPrimaryKey;
+ public bool IsNotNull => IsPrimaryKey || Metadata.GetValueOrDefault(ColumnMetadataKeys.NotNull) is true;
+ public bool IsOnlyGenerated => Metadata.GetValueOrDefault(ColumnMetadataKeys.OnlyGenerated) is true;
+ public bool IsNoUpdate => Metadata.GetValueOrDefault(ColumnMetadataKeys.NoUpdate) is true;
+ public object? DefaultValue => Metadata.GetValueOrDefault(ColumnMetadataKeys.DefaultValue);
+ /// <summary>
+ /// This only returns metadata value. It doesn't not fall back to primary column. If you want to get the real key column, go to table info.
+ /// </summary>
+ /// <seealso cref="ColumnMetadataKeys.ActAsKey"/>
+ /// <seealso cref="TableInfo.KeyColumn"/>
+ public bool IsSpecifiedAsKey => Metadata.GetValueOrDefault(ColumnMetadataKeys.ActAsKey) is true;
+ public ColumnIndexType Index => Metadata.GetValueOrDefault<ColumnIndexType?>(ColumnMetadataKeys.Index) ?? ColumnIndexType.None;
+
+ /// <summary>
+ /// Whether the column value can be generated, which means the column has a default value or a default value generator or is AUTOINCREMENT.
+ /// </summary>
+ public bool CanBeGenerated => DefaultValue is not null || DefaultValueGeneratorMethod is not null || IsAutoIncrement;
+
+ /// <summary>
+ /// The real column name. Maybe set in metadata or just the property name.
+ /// </summary>
+ /// <value></value>
+ public string ColumnName
+ {
+ get
+ {
+ object? value = Metadata.GetValueOrDefault(ColumnMetadataKeys.ColumnName);
+ Debug.Assert(value is null || value is string);
+ return ((string?)value ?? PropertyInfo?.Name) ?? throw new Exception("Failed to get column name.");
+ }
+ }
+
+ public MethodInfo? DefaultValueGeneratorMethod
+ {
+ get
+ {
+ object? value = Metadata.GetValueOrDefault(ColumnMetadataKeys.DefaultValueGenerator);
+ Debug.Assert(value is null || value is string);
+ MethodInfo? result;
+ if (value is null)
+ {
+ string methodName = ColumnName + "DefaultValueGenerator";
+ result = Table.EntityType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static);
+ }
+ else
+ {
+ string methodName = (string)value;
+ result = Table.EntityType.GetMethod(methodName, BindingFlags.Static) ?? throw new Exception("The default value generator does not exist.");
+ }
+
+ return result;
+ }
+ }
+
+ public MethodInfo? ValidatorMethod
+ {
+ get
+ {
+ object? value = Metadata.GetValueOrDefault(ColumnMetadataKeys.DefaultValueGenerator);
+ Debug.Assert(value is null || value is string);
+ MethodInfo? result;
+ if (value is null)
+ {
+ string methodName = ColumnName + "Validator";
+ result = Table.EntityType.GetMethod(methodName, BindingFlags.Static);
+ }
+ else
+ {
+ string methodName = (string)value;
+ result = Table.EntityType.GetMethod(methodName, BindingFlags.Static) ?? throw new Exception("The validator does not exist.");
+ }
+
+ return result;
+ }
+ }
+
+ public void InvokeValidator(object? value)
+ {
+ var method = this.ValidatorMethod;
+ if (method is null)
+ {
+ _logger.LogInformation("Try to invoke validator for column {} but it does not exist.", ColumnName);
+ return;
+ }
+ var parameters = method.GetParameters();
+ if (parameters.Length == 0)
+ {
+ throw new Exception("The validator method must have at least one parameter.");
+ }
+ else if (parameters.Length == 1)
+ {
+ method.Invoke(null, new object?[] { value });
+ }
+ else if (parameters.Length == 2)
+ {
+ if (parameters[0].ParameterType == typeof(ColumnInfo))
+ method.Invoke(null, new object?[] { this, value });
+ else if (parameters[1].ParameterType == typeof(ColumnInfo))
+ method.Invoke(null, new object?[] { value, this });
+ else
+ throw new Exception("The validator method must have a parameter of type ColumnInfo if it has 2 parameters.");
+ }
+ else
+ {
+ throw new Exception("The validator method can only have 1 or 2 parameters.");
+ }
+ }
+
+ public object? InvokeDefaultValueGenerator()
+ {
+ var method = this.DefaultValueGeneratorMethod;
+ if (method is null)
+ {
+ _logger.LogInformation("Try to invoke default value generator for column {} but it does not exist.", ColumnName);
+ return null;
+ }
+ var parameters = method.GetParameters();
+ if (parameters.Length == 0)
+ {
+ return method.Invoke(null, new object?[0]);
+ }
+ else if (parameters.Length == 1)
+ {
+ if (parameters[0].ParameterType != typeof(ColumnInfo))
+ throw new Exception("The default value generator method can only have a parameter of type ColumnInfo.");
+ return method.Invoke(null, new object?[] { this });
+ }
+ else
+ {
+ throw new Exception("The default value generator method can only have 0 or 1 parameter.");
+ }
+ }
+
+ public object? GenerateDefaultValue()
+ {
+ if (DefaultValueGeneratorMethod is not null)
+ {
+ return InvokeDefaultValueGenerator();
+ }
+
+ if (Metadata.TryGetValue(ColumnMetadataKeys.DefaultValue, out object? value))
+ {
+ return value;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ public string GenerateCreateTableColumnString(string? dbProviderId = null)
+ {
+ StringBuilder result = new StringBuilder();
+ result.Append(ColumnName);
+ result.Append(' ');
+ result.Append(ColumnType.GetSqlTypeString(dbProviderId));
+ if (IsPrimaryKey)
+ {
+ result.Append(' ');
+ result.Append("PRIMARY KEY");
+ }
+ else if (IsNotNull)
+ {
+ result.Append(' ');
+ result.Append("NOT NULL");
+ }
+
+ if (IsAutoIncrement)
+ {
+ result.Append(' ');
+ result.Append("AUTOINCREMENT");
+ }
+
+ return result.ToString();
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs
new file mode 100644
index 0000000..7247ff1
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs
@@ -0,0 +1,188 @@
+namespace CrupestApi.Commons.Crud;
+
+public static class ColumnMetadataKeys
+{
+ public const string ColumnName = nameof(ColumnAttribute.ColumnName);
+ public const string NotNull = nameof(ColumnAttribute.NotNull);
+ public const string IsPrimaryKey = nameof(ColumnAttribute.IsPrimaryKey);
+ public const string Index = nameof(ColumnAttribute.Index);
+
+ /// <summary>
+ /// This will add hooks for string type column to coerce null to ""(empty string) when get or set. No effect on non-string type.
+ /// </summary>
+ public const string DefaultEmptyForString = nameof(ColumnAttribute.DefaultEmptyForString);
+
+ /// <summary>
+ /// This indicates that you take care of generate this column value when create entity. User calling the api can not specify the value.
+ /// </summary>
+ public const string OnlyGenerated = nameof(ColumnAttribute.OnlyGenerated);
+
+ /// <summary>
+ /// The default value generator method name in entity type. Default to null, aka, search for ColumnNameDefaultValueGenerator.
+ /// Generator has signature <code>static void DefaultValueGenerator(ColumnInfo column)</code>
+ /// </summary>
+ public const string DefaultValueGenerator = nameof(ColumnAttribute.DefaultValueGenerator);
+
+ /// <summary>
+ /// The validator method name in entity type. Default to null, aka, the default validator.
+ /// Validator has signature <code>static void Validator(ColumnInfo column, object value)</code>
+ /// Value param is never null. If you want to mean NULL, it should be a <see cref="DbNullValue"/>.
+ /// </summary>
+ public const string Validator = nameof(ColumnAttribute.Validator);
+
+ /// <summary>
+ /// The column can only be set when inserted, can't be changed in update.
+ /// </summary>
+ /// <returns></returns>
+ public const string NoUpdate = nameof(ColumnAttribute.NoUpdate);
+
+ /// <summary>
+ /// This column acts as key when get one entity for http get method in path.
+ /// </summary>
+ public const string ActAsKey = nameof(ColumnAttribute.ActAsKey);
+
+ /// <summary>
+ /// The default value used for the column.
+ /// </summary>
+ public const string DefaultValue = nameof(ColumnAttribute.DefaultValue);
+}
+
+public interface IColumnMetadata
+{
+ bool TryGetValue(string key, out object? value);
+
+ object? GetValueOrDefault(string key)
+ {
+ if (TryGetValue(key, out var value))
+ {
+ return value;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ T? GetValueOrDefault<T>(string key)
+ {
+ return (T?)GetValueOrDefault(key);
+ }
+
+ object? this[string key]
+ {
+ get
+ {
+ if (TryGetValue(key, out var value))
+ {
+ return value;
+ }
+ else
+ {
+ throw new KeyNotFoundException("Key not found.");
+ }
+ }
+ }
+}
+
+public enum ColumnIndexType
+{
+ None,
+ Unique,
+ NonUnique
+}
+
+[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
+public class ColumnAttribute : Attribute, IColumnMetadata
+{
+ // if null, use the property name.
+ public string? ColumnName { get; init; }
+
+ // default false.
+ public bool NotNull { get; init; }
+
+ // default false
+ public bool IsPrimaryKey { get; init; }
+
+ // default None
+ public ColumnIndexType Index { get; init; } = ColumnIndexType.None;
+
+ /// <seealso cref="ColumnMetadataKeys.DefaultEmptyForString"/>
+ public bool DefaultEmptyForString { get; init; }
+
+ /// <seealso cref="ColumnMetadataKeys.OnlyGenerated"/>
+ public bool OnlyGenerated { get; init; }
+
+ /// <seealso cref="ColumnMetadataKeys.DefaultValueGenerator"/>
+ public string? DefaultValueGenerator { get; init; }
+
+ /// <seealso cref="ColumnMetadataKeys.Validator"/>
+ public string? Validator { get; init; }
+
+ /// <seealso cref="ColumnMetadataKeys.NoUpdate"/>
+ public bool NoUpdate { get; init; }
+
+ /// <seealso cref="ColumnMetadataKeys.ActAsKey"/>
+ public bool ActAsKey { get; init; }
+
+ public object? DefaultValue { get; init; }
+
+ public bool TryGetValue(string key, out object? value)
+ {
+ var property = GetType().GetProperty(key);
+ if (property is null)
+ {
+ value = null;
+ return false;
+ }
+ value = property.GetValue(this);
+ return true;
+ }
+}
+
+public class AggregateColumnMetadata : IColumnMetadata
+{
+ private IDictionary<string, object?> _own = new Dictionary<string, object?>();
+ private IList<IColumnMetadata> _children = new List<IColumnMetadata>();
+
+ public void Add(string key, object? value)
+ {
+ _own[key] = value;
+ }
+
+ public void Remove(string key)
+ {
+ _own.Remove(key);
+ }
+
+ public void Add(IColumnMetadata child)
+ {
+ _children.Add(child);
+ }
+
+ public void Remove(IColumnMetadata child)
+ {
+ _children.Remove(child);
+ }
+
+ public bool TryGetValue(string key, out object? value)
+ {
+ if (_own.ContainsKey(key))
+ {
+ value = _own[key];
+ return true;
+ }
+
+ bool found = false;
+ value = null;
+ foreach (var child in _children)
+ {
+ if (child.TryGetValue(key, out var tempValue))
+ {
+ value = tempValue;
+ found = true;
+ }
+ }
+
+ return found;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnTypeInfo.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnTypeInfo.cs
new file mode 100644
index 0000000..19eff52
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnTypeInfo.cs
@@ -0,0 +1,218 @@
+using System.Data;
+using System.Diagnostics;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace CrupestApi.Commons.Crud;
+
+public interface IColumnTypeInfo
+{
+ public static IColumnTypeInfo BoolColumnTypeInfo { get; } = new SimpleColumnTypeInfo<bool>();
+ public static IColumnTypeInfo IntColumnTypeInfo { get; } = new SimpleColumnTypeInfo<int>();
+ public static IColumnTypeInfo ShortColumnTypeInfo { get; } = new SimpleColumnTypeInfo<short>();
+ public static IColumnTypeInfo SByteColumnTypeInfo { get; } = new SimpleColumnTypeInfo<sbyte>();
+ public static IColumnTypeInfo LongColumnTypeInfo { get; } = new SimpleColumnTypeInfo<long>();
+ public static IColumnTypeInfo FloatColumnTypeInfo { get; } = new SimpleColumnTypeInfo<float>();
+ public static IColumnTypeInfo DoubleColumnTypeInfo { get; } = new SimpleColumnTypeInfo<double>();
+ public static IColumnTypeInfo StringColumnTypeInfo { get; } = new SimpleColumnTypeInfo<string>();
+ public static IColumnTypeInfo BytesColumnTypeInfo { get; } = new SimpleColumnTypeInfo<byte[]>();
+ public static IColumnTypeInfo DateTimeColumnTypeInfo { get; } = new DateTimeColumnTypeInfo();
+
+ Type ClrType { get; }
+ Type DatabaseClrType { get; }
+ bool IsSimple { get { return ClrType == DatabaseClrType; } }
+ DbType DbType
+ {
+ get
+ {
+ if (DatabaseClrType == typeof(bool))
+ {
+ return DbType.Boolean;
+ }
+ else if (DatabaseClrType == typeof(int))
+ {
+ return DbType.Int32;
+ }
+ else if (DatabaseClrType == typeof(long))
+ {
+ return DbType.Int64;
+ }
+ else if (DatabaseClrType == typeof(short))
+ {
+ return DbType.Int16;
+ }
+ else if (DatabaseClrType == typeof(sbyte))
+ {
+ return DbType.SByte;
+ }
+ else if (DatabaseClrType == typeof(double))
+ {
+ return DbType.Double;
+ }
+ else if (DatabaseClrType == typeof(float))
+ {
+ return DbType.Single;
+ }
+ else if (DatabaseClrType == typeof(string))
+ {
+ return DbType.String;
+ }
+ else if (DatabaseClrType == typeof(byte[]))
+ {
+ return DbType.Binary;
+ }
+ else
+ {
+ throw new Exception("Can't deduce DbType.");
+ }
+ }
+ }
+
+ string GetSqlTypeString(string? dbProviderId = null)
+ {
+ // Default implementation for SQLite
+ return DbType switch
+ {
+ DbType.String => "TEXT",
+ DbType.Boolean or DbType.Int16 or DbType.Int32 or DbType.Int64 => "INTEGER",
+ DbType.Single or DbType.Double => "REAL",
+ DbType.Binary => "BLOB",
+ _ => throw new Exception($"Unsupported DbType: {DbType}"),
+ };
+ }
+
+ JsonConverter? JsonConverter { get { return null; } }
+
+ // You must override this method if ClrType != DatabaseClrType
+ object? ConvertFromDatabase(object? databaseValue)
+ {
+ Debug.Assert(IsSimple);
+ return databaseValue;
+ }
+
+ // You must override this method if ClrType != DatabaseClrType
+ object? ConvertToDatabase(object? value)
+ {
+ Debug.Assert(IsSimple);
+ return value;
+ }
+}
+
+public interface IColumnTypeProvider
+{
+ IReadOnlyList<IColumnTypeInfo> GetAll();
+ IColumnTypeInfo Get(Type clrType);
+
+ IList<IColumnTypeInfo> GetAllCustom()
+ {
+ return GetAll().Where(t => !t.IsSimple).ToList();
+ }
+}
+
+public class SimpleColumnTypeInfo<T> : IColumnTypeInfo
+{
+ public Type ClrType => typeof(T);
+ public Type DatabaseClrType => typeof(T);
+}
+
+public class DateTimeColumnTypeInfo : IColumnTypeInfo
+{
+ private JsonConverter<DateTime> _jsonConverter;
+
+ public DateTimeColumnTypeInfo()
+ {
+ _jsonConverter = new DateTimeJsonConverter(this);
+ }
+
+ public Type ClrType => typeof(DateTime);
+ public Type DatabaseClrType => typeof(string);
+
+ public JsonConverter JsonConverter => _jsonConverter;
+
+ public object? ConvertToDatabase(object? value)
+ {
+ if (value is null) return null;
+ Debug.Assert(value is DateTime);
+ return ((DateTime)value).ToUniversalTime().ToString("s") + "Z";
+ }
+
+ public object? ConvertFromDatabase(object? databaseValue)
+ {
+ if (databaseValue is null) return null;
+ Debug.Assert(databaseValue is string);
+ var databaseString = (string)databaseValue;
+ var dateTimeStyles = DateTimeStyles.None;
+ if (databaseString.Length > 0 && databaseString[^1] == 'Z')
+ {
+ databaseString = databaseString.Substring(0, databaseString.Length - 1);
+ dateTimeStyles = DateTimeStyles.AssumeUniversal & DateTimeStyles.AdjustToUniversal;
+ }
+ return DateTime.ParseExact(databaseString, "s", null, dateTimeStyles);
+ }
+}
+
+public class DateTimeJsonConverter : JsonConverter<DateTime>
+{
+ private readonly DateTimeColumnTypeInfo _typeInfo;
+
+ public DateTimeJsonConverter(DateTimeColumnTypeInfo typeInfo)
+ {
+ _typeInfo = typeInfo;
+ }
+
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var databaseValue = reader.GetString();
+ return (DateTime)_typeInfo.ConvertFromDatabase(databaseValue)!;
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ {
+ var databaseValue = _typeInfo.ConvertToDatabase(value);
+ writer.WriteStringValue((string)databaseValue!);
+ }
+}
+
+public class ColumnTypeProvider : IColumnTypeProvider
+{
+ private Dictionary<Type, IColumnTypeInfo> _typeMap = new Dictionary<Type, IColumnTypeInfo>();
+
+ public ColumnTypeProvider()
+ {
+ _typeMap.Add(IColumnTypeInfo.BoolColumnTypeInfo.ClrType, IColumnTypeInfo.BoolColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.IntColumnTypeInfo.ClrType, IColumnTypeInfo.IntColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.ShortColumnTypeInfo.ClrType, IColumnTypeInfo.ShortColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.SByteColumnTypeInfo.ClrType, IColumnTypeInfo.SByteColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.LongColumnTypeInfo.ClrType, IColumnTypeInfo.LongColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.FloatColumnTypeInfo.ClrType, IColumnTypeInfo.FloatColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.DoubleColumnTypeInfo.ClrType, IColumnTypeInfo.DoubleColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.StringColumnTypeInfo.ClrType, IColumnTypeInfo.StringColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.BytesColumnTypeInfo.ClrType, IColumnTypeInfo.BytesColumnTypeInfo);
+ _typeMap.Add(IColumnTypeInfo.DateTimeColumnTypeInfo.ClrType, IColumnTypeInfo.DateTimeColumnTypeInfo);
+ }
+
+ public IReadOnlyList<IColumnTypeInfo> GetAll()
+ {
+ return _typeMap.Values.ToList();
+ }
+
+ // This is thread-safe.
+ public IColumnTypeInfo Get(Type clrType)
+ {
+ if (_typeMap.TryGetValue(clrType, out var typeInfo))
+ {
+ return typeInfo;
+ }
+ else
+ {
+ if (clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(Nullable<>))
+ {
+ clrType = clrType.GetGenericArguments()[0];
+ return Get(clrType);
+ }
+
+ throw new Exception($"Unsupported type: {clrType}");
+ }
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs
new file mode 100644
index 0000000..1e881d3
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs
@@ -0,0 +1,132 @@
+using System.Data;
+using CrupestApi.Commons.Crud.Migrations;
+
+namespace CrupestApi.Commons.Crud;
+
+[Flags]
+public enum UpdateBehavior
+{
+ None = 0,
+ SaveNull = 1
+}
+
+public class CrudService<TEntity> : IDisposable where TEntity : class
+{
+ protected readonly TableInfo _table;
+ protected readonly string? _connectionName;
+ protected readonly IDbConnection _dbConnection;
+ private readonly bool _shouldDisposeConnection;
+ private IDatabaseMigrator _migrator;
+ private readonly ILogger<CrudService<TEntity>> _logger;
+
+ public CrudService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, IDatabaseMigrator migrator, ILoggerFactory loggerFactory)
+ {
+ _connectionName = GetConnectionName();
+ _table = tableInfoFactory.Get(typeof(TEntity));
+ _dbConnection = dbConnectionFactory.Get(_connectionName);
+ _shouldDisposeConnection = dbConnectionFactory.ShouldDisposeConnection;
+ _migrator = migrator;
+ _logger = loggerFactory.CreateLogger<CrudService<TEntity>>();
+ }
+
+ protected virtual void EnsureDatabase()
+ {
+ if (_migrator.NeedMigrate(_dbConnection, _table))
+ {
+ _logger.LogInformation($"Entity {_table.TableName} needs migration.");
+ _migrator.AutoMigrate(_dbConnection, _table);
+ }
+ }
+
+ protected virtual string GetConnectionName()
+ {
+ return typeof(TEntity).Name;
+ }
+
+ protected virtual void AfterMigrate(IDbConnection dbConnection, TableInfo tableInfo)
+ {
+
+ }
+
+ public void Dispose()
+ {
+ if (_shouldDisposeConnection)
+ _dbConnection.Dispose();
+ }
+
+ public List<TEntity> GetAll()
+ {
+ EnsureDatabase();
+ var result = _table.Select<TEntity>(_dbConnection, null);
+ return result;
+ }
+
+ public int GetCount()
+ {
+ EnsureDatabase();
+ var result = _table.SelectCount(_dbConnection);
+ return result;
+ }
+
+ public TEntity GetByKey(object key)
+ {
+ EnsureDatabase();
+ var result = _table.Select<TEntity>(_dbConnection, null, WhereClause.Create().Eq(_table.KeyColumn.ColumnName, key)).SingleOrDefault();
+ if (result is null)
+ {
+ throw new EntityNotExistException($"Required entity for key {key} not found.");
+ }
+ return result;
+ }
+
+ public IInsertClause ConvertEntityToInsertClauses(TEntity entity)
+ {
+ var result = new InsertClause();
+ foreach (var column in _table.PropertyColumns)
+ {
+ var value = column.PropertyInfo!.GetValue(entity);
+ result.Add(column.ColumnName, value);
+ }
+ return result;
+ }
+
+ public object Create(TEntity entity)
+ {
+ EnsureDatabase();
+ var insertClause = ConvertEntityToInsertClauses(entity);
+ _table.Insert(_dbConnection, insertClause, out var key);
+ return key;
+ }
+
+ public IUpdateClause ConvertEntityToUpdateClauses(TEntity entity, UpdateBehavior behavior)
+ {
+ var result = UpdateClause.Create();
+ var saveNull = behavior.HasFlag(UpdateBehavior.SaveNull);
+ foreach (var column in _table.PropertyColumns)
+ {
+ var value = column.PropertyInfo!.GetValue(entity);
+ if (!saveNull && value is null) continue;
+ result.Add(column.ColumnName, value);
+ }
+ return result;
+ }
+
+ // Return new key.
+ public object UpdateByKey(object key, TEntity entity, UpdateBehavior behavior = UpdateBehavior.None)
+ {
+ EnsureDatabase();
+ var affectedCount = _table.Update(_dbConnection, WhereClause.Create().Eq(_table.KeyColumn.ColumnName, key),
+ ConvertEntityToUpdateClauses(entity, behavior), out var newKey);
+ if (affectedCount == 0)
+ {
+ throw new EntityNotExistException($"Required entity for key {key} not found.");
+ }
+ return newKey ?? key;
+ }
+
+ public bool DeleteByKey(object key)
+ {
+ EnsureDatabase();
+ return _table.Delete(_dbConnection, WhereClause.Create().Eq(_table.KeyColumn.ColumnName, key)) == 1;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudServiceCollectionExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudServiceCollectionExtensions.cs
new file mode 100644
index 0000000..a7e5193
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudServiceCollectionExtensions.cs
@@ -0,0 +1,34 @@
+using CrupestApi.Commons.Crud.Migrations;
+using CrupestApi.Commons.Secrets;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CrupestApi.Commons.Crud;
+
+public static class CrudServiceCollectionExtensions
+{
+ public static IServiceCollection AddCrudCore(this IServiceCollection services)
+ {
+ services.TryAddSingleton<IDbConnectionFactory, SqliteConnectionFactory>();
+ services.TryAddSingleton<IColumnTypeProvider, ColumnTypeProvider>();
+ services.TryAddSingleton<ITableInfoFactory, TableInfoFactory>();
+ services.TryAddSingleton<IDatabaseMigrator, SqliteDatabaseMigrator>();
+ services.AddSecrets();
+ return services;
+ }
+
+ public static IServiceCollection AddCrud<TEntity, TCrudService>(this IServiceCollection services) where TEntity : class where TCrudService : CrudService<TEntity>
+ {
+ AddCrudCore(services);
+
+ services.TryAddScoped<CrudService<TEntity>, TCrudService>();
+ services.TryAddScoped<EntityJsonHelper<TEntity>>();
+
+ return services;
+ }
+
+ public static IServiceCollection AddCrud<TEntity>(this IServiceCollection services) where TEntity : class
+ {
+ return services.AddCrud<TEntity, CrudService<TEntity>>();
+ }
+
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs
new file mode 100644
index 0000000..8942979
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs
@@ -0,0 +1,101 @@
+namespace CrupestApi.Commons.Crud;
+
+public static class CrudWebApplicationExtensions
+{
+ public static WebApplication UseCrudCore(this WebApplication app)
+ {
+ app.Use(async (context, next) =>
+ {
+ try
+ {
+ await next();
+ }
+ catch (EntityNotExistException)
+ {
+ await context.ResponseMessageAsync("Requested entity does not exist.", StatusCodes.Status404NotFound);
+ }
+ catch (UserException e)
+ {
+ await context.ResponseMessageAsync(e.Message);
+ }
+ });
+
+ return app;
+ }
+
+ public static WebApplication MapCrud<TEntity>(this WebApplication app, string path, string? permission) where TEntity : class
+ {
+ app.MapGet(path, async (context) =>
+ {
+ if (!context.RequirePermission(permission)) return;
+ var crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();
+ var entityJsonHelper = context.RequestServices.GetRequiredService<EntityJsonHelper<TEntity>>();
+ var allEntities = crudService.GetAll();
+ await context.ResponseJsonAsync(allEntities.Select(e => entityJsonHelper.ConvertEntityToDictionary(e)));
+ });
+
+ app.MapGet(path + "/{key}", async (context) =>
+ {
+ if (!context.RequirePermission(permission)) return;
+ var crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();
+ var entityJsonHelper = context.RequestServices.GetRequiredService<EntityJsonHelper<TEntity>>();
+ var key = context.Request.RouteValues["key"]?.ToString();
+ if (key == null)
+ {
+ await context.ResponseMessageAsync("Please specify a key in path.");
+ return;
+ }
+
+ var entity = crudService.GetByKey(key);
+ await context.ResponseJsonAsync(entityJsonHelper.ConvertEntityToDictionary(entity));
+ });
+
+ app.MapPost(path, async (context) =>
+ {
+ if (!context.RequirePermission(permission)) return;
+ var crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();
+ var entityJsonHelper = context.RequestServices.GetRequiredService<EntityJsonHelper<TEntity>>();
+ var jsonDocument = await context.Request.ReadJsonAsync();
+ var key = crudService.Create(entityJsonHelper.ConvertJsonToEntityForInsert(jsonDocument.RootElement));
+ await context.ResponseJsonAsync(entityJsonHelper.ConvertEntityToDictionary(crudService.GetByKey(key)));
+ });
+
+ app.MapPatch(path + "/{key}", async (context) =>
+ {
+ if (!context.RequirePermission(permission)) return;
+ var key = context.Request.RouteValues["key"]?.ToString();
+ var crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();
+ var entityJsonHelper = context.RequestServices.GetRequiredService<EntityJsonHelper<TEntity>>();
+ if (key == null)
+ {
+ await context.ResponseMessageAsync("Please specify a key in path.");
+ return;
+ }
+
+ var jsonDocument = await context.Request.ReadJsonAsync();
+ var entity = entityJsonHelper.ConvertJsonToEntityForUpdate(jsonDocument.RootElement, out var updateBehavior);
+ var newKey = crudService.UpdateByKey(key, entity, updateBehavior);
+ await context.ResponseJsonAsync(entityJsonHelper.ConvertEntityToDictionary(crudService.GetByKey(newKey)));
+ });
+
+ app.MapDelete(path + "/{key}", async (context) =>
+ {
+ if (!context.RequirePermission(permission)) return;
+ var crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();
+ var key = context.Request.RouteValues["key"]?.ToString();
+ if (key == null)
+ {
+ await context.ResponseMessageAsync("Please specify a key in path.");
+ return;
+ }
+
+ var deleted = crudService.DeleteByKey(key);
+ if (deleted)
+ await context.ResponseMessageAsync("Deleted.", StatusCodes.Status200OK);
+ else
+ await context.ResponseMessageAsync("Not exist.", StatusCodes.Status200OK);
+ });
+
+ return app;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs
new file mode 100644
index 0000000..701622c
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs
@@ -0,0 +1,75 @@
+using System.Data;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Options;
+
+namespace CrupestApi.Commons.Crud;
+
+public interface IDbConnectionFactory
+{
+ IDbConnection Get(string? name = null);
+ bool ShouldDisposeConnection { get; }
+}
+
+public class SqliteConnectionFactory : IDbConnectionFactory
+{
+ private readonly IOptionsMonitor<CrupestApiConfig> _apiConfigMonitor;
+
+ public SqliteConnectionFactory(IOptionsMonitor<CrupestApiConfig> apiConfigMonitor)
+ {
+ _apiConfigMonitor = apiConfigMonitor;
+ }
+
+ public IDbConnection Get(string? name = null)
+ {
+ var connectionString = new SqliteConnectionStringBuilder()
+ {
+ DataSource = Path.Combine(_apiConfigMonitor.CurrentValue.DataDir, $"{name ?? "crupest-api"}.db"),
+ Mode = SqliteOpenMode.ReadWriteCreate
+ }.ToString();
+
+ var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ return connection;
+ }
+
+ public bool ShouldDisposeConnection => true;
+}
+
+public class SqliteMemoryConnectionFactory : IDbConnectionFactory, IDisposable
+{
+ private readonly Dictionary<string, IDbConnection> _connections = new();
+
+ public IDbConnection Get(string? name = null)
+ {
+ name = name ?? "crupest-api";
+
+ if (_connections.TryGetValue(name, out var connection))
+ {
+ return connection;
+ }
+ else
+ {
+ var connectionString = new SqliteConnectionStringBuilder()
+ {
+ DataSource = ":memory:",
+ Mode = SqliteOpenMode.ReadWriteCreate
+ }.ToString();
+
+ connection = new SqliteConnection(connectionString);
+ _connections.Add(name, connection);
+ connection.Open();
+ return connection;
+ }
+ }
+
+ public bool ShouldDisposeConnection => false;
+
+
+ public void Dispose()
+ {
+ foreach (var connection in _connections.Values)
+ {
+ connection.Dispose();
+ }
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbNullValue.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbNullValue.cs
new file mode 100644
index 0000000..5dc5a61
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbNullValue.cs
@@ -0,0 +1,9 @@
+namespace CrupestApi.Commons.Crud;
+
+/// <summary>
+/// This will always represent null value in database.
+/// </summary>
+public class DbNullValue
+{
+ public static DbNullValue Instance { get; } = new DbNullValue();
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs
new file mode 100644
index 0000000..cf3f178
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs
@@ -0,0 +1,206 @@
+using System.Globalization;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+
+namespace CrupestApi.Commons.Crud;
+
+/// <summary>
+/// Contains all you need to do with json.
+/// </summary>
+public class EntityJsonHelper<TEntity> where TEntity : class
+{
+ private readonly TableInfo _table;
+ private readonly IOptionsMonitor<JsonSerializerOptions> _jsonSerializerOptions;
+
+ public EntityJsonHelper(ITableInfoFactory tableInfoFactory, IOptionsMonitor<JsonSerializerOptions> jsonSerializerOptions)
+ {
+ _table = tableInfoFactory.Get(typeof(TEntity));
+ _jsonSerializerOptions = jsonSerializerOptions;
+ }
+
+ public Dictionary<string, object?> ConvertEntityToDictionary(TEntity entity, bool includeNonColumnProperties = false)
+ {
+ var result = new Dictionary<string, object?>();
+
+ foreach (var column in _table.PropertyColumns)
+ {
+ var value = column.PropertyInfo!.GetValue(entity);
+ var realValue = column.ColumnType.ConvertToDatabase(value);
+ result[column.ColumnName] = realValue;
+ }
+
+ if (includeNonColumnProperties)
+ {
+ foreach (var propertyInfo in _table.NonColumnProperties)
+ {
+ var value = propertyInfo.GetValue(entity);
+ result[propertyInfo.Name] = value;
+ }
+ }
+
+ return result;
+ }
+
+ public string ConvertEntityToJson(TEntity entity, bool includeNonColumnProperties = false)
+ {
+ var dictionary = ConvertEntityToDictionary(entity, includeNonColumnProperties);
+ return JsonSerializer.Serialize(dictionary, _jsonSerializerOptions.CurrentValue);
+ }
+
+ private object? ConvertJsonValue(JsonElement? optionalJsonElement, Type type, string propertyName)
+ {
+ if (optionalJsonElement is null)
+ {
+ return null;
+ }
+
+ var jsonElement = optionalJsonElement.Value;
+
+ if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ return null;
+ }
+
+ if (jsonElement.ValueKind is JsonValueKind.String)
+ {
+ if (type != typeof(string))
+ {
+ throw new UserException($"Property {propertyName} must be a string.");
+ }
+ return jsonElement.GetString()!;
+ }
+
+ if (jsonElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ if (type != typeof(bool))
+ {
+ throw new UserException($"Property {propertyName} must be a boolean.");
+ }
+ return jsonElement.GetBoolean();
+ }
+
+ if (jsonElement.ValueKind is JsonValueKind.Number)
+ {
+ try
+ {
+ return Convert.ChangeType(jsonElement.GetRawText(), type, CultureInfo.InvariantCulture);
+ }
+ catch (Exception)
+ {
+ throw new UserException($"Property {propertyName} must be a valid number.");
+ }
+ }
+
+ throw new UserException($"Property {propertyName} is of wrong type.");
+ }
+
+ public Dictionary<string, JsonElement> ConvertJsonObjectToDictionary(JsonElement jsonElement)
+ {
+ var result = new Dictionary<string, JsonElement>();
+
+ foreach (var property in jsonElement.EnumerateObject())
+ {
+ result[property.Name.ToLower()] = property.Value;
+ }
+
+ return result;
+ }
+
+ public TEntity ConvertJsonToEntityForInsert(JsonElement jsonElement)
+ {
+ if (jsonElement.ValueKind is not JsonValueKind.Object)
+ throw new ArgumentException("The jsonElement must be an object.");
+
+ var result = Activator.CreateInstance<TEntity>();
+
+ Dictionary<string, JsonElement> jsonProperties = ConvertJsonObjectToDictionary(jsonElement);
+
+ foreach (var column in _table.PropertyColumns)
+ {
+ var jsonPropertyValue = jsonProperties.GetValueOrDefault(column.ColumnName.ToLower());
+ var value = ConvertJsonValue(jsonPropertyValue, column.ColumnType.DatabaseClrType, column.ColumnName);
+ if (column.IsOnlyGenerated && value is not null)
+ {
+ throw new UserException($"Property {column.ColumnName} is auto generated, you cannot set it.");
+ }
+ if (!column.CanBeGenerated && value is null && column.IsNotNull)
+ {
+ throw new UserException($"Property {column.ColumnName} can NOT be generated, you must set it.");
+ }
+ var realValue = column.ColumnType.ConvertFromDatabase(value);
+ column.PropertyInfo!.SetValue(result, realValue);
+ }
+
+ return result;
+ }
+
+ public TEntity ConvertJsonToEntityForInsert(string json)
+ {
+ var jsonElement = JsonSerializer.Deserialize<JsonElement>(json, _jsonSerializerOptions.CurrentValue);
+ return ConvertJsonToEntityForInsert(jsonElement!);
+ }
+
+ public TEntity ConvertJsonToEntityForUpdate(JsonElement jsonElement, out UpdateBehavior updateBehavior)
+ {
+ if (jsonElement.ValueKind is not JsonValueKind.Object)
+ throw new UserException("The jsonElement must be an object.");
+
+ updateBehavior = UpdateBehavior.None;
+
+ Dictionary<string, JsonElement> jsonProperties = ConvertJsonObjectToDictionary(jsonElement);
+
+ bool saveNull = false;
+ if (jsonProperties.TryGetValue("$saveNull".ToLower(), out var saveNullValue))
+ {
+ if (saveNullValue.ValueKind is JsonValueKind.True)
+ {
+ updateBehavior |= UpdateBehavior.SaveNull;
+ saveNull = true;
+ }
+ else if (saveNullValue.ValueKind is JsonValueKind.False)
+ {
+
+ }
+ else
+ {
+ throw new UserException("The $saveNull must be a boolean.");
+ }
+ }
+
+ var result = Activator.CreateInstance<TEntity>();
+ foreach (var column in _table.PropertyColumns)
+ {
+ if (jsonProperties.TryGetValue(column.ColumnName.ToLower(), out var jsonPropertyValue))
+ {
+ if (jsonPropertyValue.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ if ((column.IsOnlyGenerated || column.IsNoUpdate) && saveNull)
+ {
+ throw new UserException($"Property {column.ColumnName} is auto generated or not updatable, you cannot set it.");
+ }
+
+ column.PropertyInfo!.SetValue(result, null);
+ }
+ else
+ {
+ if (column.IsOnlyGenerated || column.IsNoUpdate)
+ {
+ throw new UserException($"Property {column.ColumnName} is auto generated or not updatable, you cannot set it.");
+ }
+
+ var value = ConvertJsonValue(jsonPropertyValue, column.ColumnType.DatabaseClrType, column.ColumnName);
+ var realValue = column.ColumnType.ConvertFromDatabase(value);
+ column.PropertyInfo!.SetValue(result, realValue);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public TEntity ConvertJsonToEntityForUpdate(string json, out UpdateBehavior updateBehavior)
+ {
+ var jsonElement = JsonSerializer.Deserialize<JsonElement>(json, _jsonSerializerOptions.CurrentValue);
+ return ConvertJsonToEntityForUpdate(jsonElement!, out updateBehavior);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/IClause.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/IClause.cs
new file mode 100644
index 0000000..964a669
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/IClause.cs
@@ -0,0 +1,24 @@
+using Dapper;
+
+namespace CrupestApi.Commons.Crud;
+
+public interface IClause
+{
+ IEnumerable<IClause> GetSubclauses()
+ {
+ return Enumerable.Empty<IClause>();
+ }
+
+ IEnumerable<string> GetRelatedColumns()
+ {
+ var subclauses = GetSubclauses();
+ var result = new List<string>();
+ foreach (var subclause in subclauses)
+ {
+ var columns = subclause.GetRelatedColumns();
+ if (columns is not null)
+ result.AddRange(columns);
+ }
+ return result;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/InsertClause.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/InsertClause.cs
new file mode 100644
index 0000000..a880e66
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/InsertClause.cs
@@ -0,0 +1,77 @@
+using System.Text;
+
+namespace CrupestApi.Commons.Crud;
+
+public class InsertItem
+{
+ /// <summary>
+ /// Null means use default value. Use <see cref="DbNullValue"/>.
+ /// </summary>
+ public InsertItem(string columnName, object? value)
+ {
+ ColumnName = columnName;
+ Value = value;
+ }
+
+ public string ColumnName { get; set; }
+ public object? Value { get; set; }
+}
+
+public interface IInsertClause : IClause
+{
+ List<InsertItem> Items { get; }
+ string GenerateColumnListSql(string? dbProviderId = null);
+ (string sql, ParamList parameters) GenerateValueListSql(string? dbProviderId = null);
+}
+
+public class InsertClause : IInsertClause
+{
+ public List<InsertItem> Items { get; } = new List<InsertItem>();
+
+ public InsertClause(params InsertItem[] items)
+ {
+ Items.AddRange(items);
+ }
+
+ public InsertClause Add(params InsertItem[] items)
+ {
+ Items.AddRange(items);
+ return this;
+ }
+
+ public InsertClause Add(string column, object? value)
+ {
+ return Add(new InsertItem(column, value));
+ }
+
+ public static InsertClause Create(params InsertItem[] items)
+ {
+ return new InsertClause(items);
+ }
+
+ public List<string> GetRelatedColumns()
+ {
+ return Items.Select(i => i.ColumnName).ToList();
+ }
+
+ public string GenerateColumnListSql(string? dbProviderId = null)
+ {
+ return string.Join(", ", Items.Select(i => i.ColumnName));
+ }
+
+ public (string sql, ParamList parameters) GenerateValueListSql(string? dbProviderId = null)
+ {
+ var parameters = new ParamList();
+ var sb = new StringBuilder();
+ for (var i = 0; i < Items.Count; i++)
+ {
+ var item = Items[i];
+ var parameterName = parameters.AddRandomNameParameter(item.Value, item.ColumnName);
+ sb.Append($"@{parameterName}");
+ if (i != Items.Count - 1)
+ sb.Append(", ");
+ }
+
+ return (sb.ToString(), parameters);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/DatabaseMigrator.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/DatabaseMigrator.cs
new file mode 100644
index 0000000..f1ae616
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/DatabaseMigrator.cs
@@ -0,0 +1,44 @@
+using System.Data;
+
+namespace CrupestApi.Commons.Crud.Migrations;
+
+public class TableColumn
+{
+ public TableColumn(string name, string type, bool notNull, int primaryKey)
+ {
+ Name = name;
+ Type = type;
+ NotNull = notNull;
+ PrimaryKey = primaryKey;
+ }
+
+ public string Name { get; set; }
+ public string Type { get; set; }
+ public bool NotNull { get; set; }
+
+ /// <summary>
+ /// 0 if not primary key. 1-based index if in primary key.
+ /// </summary>
+ public int PrimaryKey { get; set; }
+}
+
+public class Table
+{
+ public Table(string name)
+ {
+ Name = name;
+ }
+
+ public string Name { get; set; }
+ public List<TableColumn> Columns { get; set; } = new List<TableColumn>();
+}
+
+public interface IDatabaseMigrator
+{
+ Table? GetTable(IDbConnection dbConnection, string tableName);
+ Table ConvertTableInfoToTable(TableInfo tableInfo);
+ string GenerateCreateTableColumnSqlSegment(TableColumn column);
+ string GenerateCreateTableSql(string tableName, IEnumerable<TableColumn> columns);
+ bool NeedMigrate(IDbConnection dbConnection, TableInfo tableInfo);
+ void AutoMigrate(IDbConnection dbConnection, TableInfo tableInfo);
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/SqliteDatabaseMigrator.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/SqliteDatabaseMigrator.cs
new file mode 100644
index 0000000..33310d6
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/Migrations/SqliteDatabaseMigrator.cs
@@ -0,0 +1,175 @@
+using System.Data;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Dapper;
+
+namespace CrupestApi.Commons.Crud.Migrations;
+
+public class SqliteDatabaseMigrator : IDatabaseMigrator
+{
+ private void CheckTableName(string name)
+ {
+ if (Regex.Match(name, @"^[_0-9a-zA-Z]+$").Success is false)
+ {
+ throw new ArgumentException("Fxxk, what have you passed as table name.");
+ }
+ }
+
+ public Table? GetTable(IDbConnection dbConnection, string tableName)
+ {
+ var count = dbConnection.QuerySingle<int>(
+ "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = @TableName;",
+ new { TableName = tableName });
+ if (count == 0)
+ {
+ return null;
+ }
+ else if (count > 1)
+ {
+ throw new Exception($"More than 1 table has name {tableName}. What happened?");
+ }
+ else
+ {
+ var table = new Table(tableName);
+ var queryColumns = dbConnection.Query<dynamic>($"PRAGMA table_info({tableName})");
+
+ foreach (var column in queryColumns)
+ {
+ var columnName = (string)column.name;
+ var columnType = (string)column.type;
+ var isNullable = Convert.ToBoolean(column.notnull);
+ var primaryKey = Convert.ToInt32(column.pk);
+
+ table.Columns.Add(new TableColumn(columnName, columnType, isNullable, primaryKey));
+ }
+
+ return table;
+ }
+ }
+
+ public Table ConvertTableInfoToTable(TableInfo tableInfo)
+ {
+ var table = new Table(tableInfo.TableName);
+
+ foreach (var columnInfo in tableInfo.Columns)
+ {
+ table.Columns.Add(new TableColumn(columnInfo.ColumnName, columnInfo.ColumnType.GetSqlTypeString(),
+ columnInfo.IsNotNull, columnInfo.IsPrimaryKey ? 1 : 0));
+ }
+
+ return table;
+ }
+
+ public string GenerateCreateTableColumnSqlSegment(TableColumn column)
+ {
+ StringBuilder result = new StringBuilder();
+ result.Append(column.Name);
+ result.Append(' ');
+ result.Append(column.Type);
+ if (column.PrimaryKey is not 0)
+ {
+ result.Append(" PRIMARY KEY AUTOINCREMENT");
+ }
+ else if (column.NotNull)
+ {
+ result.Append(" NOT NULL");
+ }
+
+ return result.ToString();
+ }
+
+ public string GenerateCreateTableSql(string tableName, IEnumerable<TableColumn> columns)
+ {
+ CheckTableName(tableName);
+
+ var sql = $@"
+CREATE TABLE {tableName} (
+ {string.Join(",\n ", columns.Select(GenerateCreateTableColumnSqlSegment))}
+);
+ ".Trim();
+
+ return sql;
+
+ }
+
+ public void AutoMigrate(IDbConnection dbConnection, TableInfo tableInfo)
+ {
+ var tableName = tableInfo.TableName;
+ var databaseTable = GetTable(dbConnection, tableName);
+ var wantedTable = ConvertTableInfoToTable(tableInfo);
+ var databaseTableColumnNames = databaseTable is null ? new List<string>() : databaseTable.Columns.Select(column => column.Name).ToList();
+ var wantedTableColumnNames = wantedTable.Columns.Select(column => column.Name).ToList();
+
+ var notChangeColumns = wantedTableColumnNames.Where(column => databaseTableColumnNames.Contains(column)).ToList();
+ var addColumns = wantedTableColumnNames.Where(column => !databaseTableColumnNames.Contains(column)).ToList();
+
+ if (databaseTable is not null && dbConnection.QuerySingle<int>($"SELECT count(*) FROM {tableName}") > 0)
+ {
+ foreach (var columnName in addColumns)
+ {
+ var columnInfo = tableInfo.GetColumn(columnName);
+ if (!columnInfo.CanBeGenerated)
+ {
+ throw new Exception($"Column {columnName} cannot be generated. So we can't auto-migrate.");
+ }
+ }
+ }
+
+ // We are sqlite, so it's a little bit difficult.
+ using var transaction = dbConnection.BeginTransaction();
+
+ if (databaseTable is not null)
+ {
+ var tempTableName = tableInfo.TableName + "_temp";
+ dbConnection.Execute($"ALTER TABLE {tableName} RENAME TO {tempTableName}", new { TableName = tableName, tempTableName });
+
+ var createTableSql = GenerateCreateTableSql(tableName, wantedTable.Columns);
+ dbConnection.Execute(createTableSql);
+
+ // Copy old data to new table.
+ var originalRows = dbConnection.Query<dynamic>($"SELECT * FROM {tempTableName}").Cast<IDictionary<string, object?>>().ToList();
+ foreach (var originalRow in originalRows)
+ {
+ var parameters = new DynamicParameters();
+
+ foreach (var columnName in notChangeColumns)
+ {
+ parameters.Add(columnName, originalRow[columnName]);
+ }
+
+ foreach (var columnName in addColumns)
+ {
+ parameters.Add(columnName, tableInfo.GetColumn(columnName).GenerateDefaultValue());
+ }
+
+ string columnSql = string.Join(", ", wantedTableColumnNames);
+ string valuesSql = string.Join(", ", wantedTableColumnNames.Select(c => "@" + c));
+
+ string sql = $"INSERT INTO {tableName} ({columnSql}) VALUES {valuesSql})";
+ dbConnection.Execute(sql, parameters);
+ }
+
+ // Finally drop old table
+ dbConnection.Execute($"DROP TABLE {tempTableName}");
+ }
+ else
+ {
+ var createTableSql = GenerateCreateTableSql(tableName, wantedTable.Columns);
+ dbConnection.Execute(createTableSql);
+ }
+
+ // Commit transaction.
+ transaction.Commit();
+ }
+
+ public bool NeedMigrate(IDbConnection dbConnection, TableInfo tableInfo)
+ {
+ var tableName = tableInfo.TableName;
+ var databaseTable = GetTable(dbConnection, tableName);
+ var wantedTable = ConvertTableInfoToTable(tableInfo);
+ var databaseTableColumns = databaseTable is null ? new HashSet<string>() : new HashSet<string>(databaseTable.Columns.Select(c => c.Name));
+ var wantedTableColumns = new HashSet<string>(wantedTable.Columns.Select(c => c.Name));
+ return !databaseTableColumns.SetEquals(wantedTableColumns);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/OrderByClause.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/OrderByClause.cs
new file mode 100644
index 0000000..734d044
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/OrderByClause.cs
@@ -0,0 +1,50 @@
+namespace CrupestApi.Commons.Crud;
+
+public class OrderByItem
+{
+ public OrderByItem(string columnName, bool isAscending)
+ {
+ ColumnName = columnName;
+ IsAscending = isAscending;
+ }
+
+ public string ColumnName { get; }
+ public bool IsAscending { get; }
+
+ public string GenerateSql()
+ {
+ return $"{ColumnName} {(IsAscending ? "ASC" : "DESC")}";
+ }
+}
+
+public interface IOrderByClause : IClause
+{
+ List<OrderByItem> Items { get; }
+ // Contains "ORDER BY" keyword!
+ string GenerateSql(string? dbProviderId = null);
+}
+
+public class OrderByClause : IOrderByClause
+{
+ public List<OrderByItem> Items { get; } = new List<OrderByItem>();
+
+ public OrderByClause(params OrderByItem[] items)
+ {
+ Items.AddRange(items);
+ }
+
+ public static OrderByClause Create(params OrderByItem[] items)
+ {
+ return new OrderByClause(items);
+ }
+
+ public List<string> GetRelatedColumns()
+ {
+ return Items.Select(x => x.ColumnName).ToList();
+ }
+
+ public string GenerateSql(string? dbProviderId = null)
+ {
+ return "ORDER BY " + string.Join(", ", Items.Select(i => i.GenerateSql()));
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ParamMap.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ParamMap.cs
new file mode 100644
index 0000000..37d77ca
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ParamMap.cs
@@ -0,0 +1,73 @@
+using System.Data;
+using System.Diagnostics;
+
+namespace CrupestApi.Commons.Crud;
+
+/// <summary>
+/// <see cref="ColumnName"/> is an optional column name related to the param. You may use it to do some column related things. Like use a more accurate conversion.
+/// </summary>
+/// <remarks>
+/// If value is DbNullValue, it will be treated as null.
+/// </remarks>
+public record ParamInfo(string Name, object? Value, string? ColumnName = null);
+
+public class ParamList : List<ParamInfo>
+{
+ private static Random random = new Random();
+ private const string chars = "abcdefghijklmnopqrstuvwxyz";
+ public static string GenerateRandomKey(int length)
+ {
+ lock (random)
+ {
+ var result = new string(Enumerable.Repeat(chars, length)
+ .Select(s => s[random.Next(s.Length)]).ToArray());
+ return result;
+ }
+ }
+
+ public string GenerateRandomParameterName()
+ {
+ var parameterName = GenerateRandomKey(10);
+ int retryTimes = 1;
+ while (ContainsKey(parameterName))
+ {
+ retryTimes++;
+ Debug.Assert(retryTimes <= 100);
+ parameterName = GenerateRandomKey(10);
+ }
+ return parameterName;
+ }
+
+
+ public bool ContainsKey(string name)
+ {
+ return this.SingleOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) is not null;
+ }
+
+ public T? Get<T>(string key)
+ {
+ return (T?)this.SingleOrDefault(p => p.Name.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
+ }
+
+ public object? this[string key]
+ {
+ get
+ {
+ return this.SingleOrDefault(p => p.Name.Equals(key, StringComparison.OrdinalIgnoreCase)) ?? throw new KeyNotFoundException("Key not found.");
+ }
+ }
+
+ public void Add(string name, object? value, string? columnName = null)
+ {
+ Add(new ParamInfo(name, value, columnName));
+ }
+
+ // Return the random name.
+ public string AddRandomNameParameter(object? value, string? columnName = null)
+ {
+ var parameterName = GenerateRandomParameterName();
+ var param = new ParamInfo(parameterName, value, columnName);
+ Add(param);
+ return parameterName;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/README.md b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/README.md
new file mode 100644
index 0000000..b008ea7
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/README.md
@@ -0,0 +1,47 @@
+# CRUD Technic Notes
+
+## Overview
+
+The ultimate CRUD scaffold finally comes.
+
+## Database Pipeline
+
+### Select
+
+1. Create select `what`, where clause, order clause, `Offset` and `Limit`.
+2. Check clauses' related columns are valid.
+3. Generate sql string and param list.
+4. Convert param list to `Dapper` dynamic params with proper type conversion in `IColumnTypeInfo`.
+5. Execute sql and get `dynamic`s.
+6. (Optional) Convert `dynamic`s to `TEntity`s.
+
+### Insert
+
+1. Create insert clause.
+2. Check clauses' related columns are valid.
+3. Create a real empty insert clause.
+4. For each column:
+ 1. If insert item exists and value is not null but the column `IsGenerated` is true, throw exception.
+ 2. If insert item does not exist or value is `null`, use default value generator to generate value. However, `DbNullValue` always means use `NULL` for that column.
+ 3. If value is `null` and the column `IsAutoIncrement` is true, skip to next column.
+ 4. Coerce null to `DbNullValue`.
+ 5. Run validator to validate the value.
+ 6. If value is `DbNullValue`, `IsNotNull` is true, throw exception.
+ 7. Add column and value to real insert clause.
+5. Generate sql string and param list.
+6. Convert param list to `Dapper` dynamic params with proper type conversion in `IColumnTypeInfo`.
+7. Execute sql and return `KeyColumn` value.
+
+### Update
+
+1. Create update clause, where clause.
+2. Check clauses' related columns are valid. Then generate sql string and param list.
+3. Create a real empty update clause.
+4. For each column:
+ 1. If update item exists and value is not null but the column `IsNoUpdate` is true, throw exception.
+ 2. Invoke validator to validate the value.
+ 3. If `IsNotNull` is true and value is `DbNullValue`, throw exception.
+ 4. Add column and value to real update clause.
+5. Generate sql string and param list.
+6. Convert param list to `Dapper` dynamic params with proper type conversion in `IColumnTypeInfo`.
+7. Execute sql and return count of affected rows.
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs
new file mode 100644
index 0000000..4a7ea95
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs
@@ -0,0 +1,628 @@
+using System.Data;
+using System.Diagnostics;
+using System.Reflection;
+using System.Text;
+using Dapper;
+
+namespace CrupestApi.Commons.Crud;
+
+/// <summary>
+/// Contains all you need to manipulate a table.
+/// </summary>
+public class TableInfo
+{
+ private readonly IColumnTypeProvider _columnTypeProvider;
+ private readonly Lazy<List<string>> _lazyColumnNameList;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ILogger<TableInfo> _logger;
+
+ public TableInfo(Type entityType, IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory)
+ : this(entityType.Name, entityType, columnTypeProvider, loggerFactory)
+ {
+ }
+
+ public TableInfo(string tableName, Type entityType, IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory)
+ {
+ _loggerFactory = loggerFactory;
+ _logger = loggerFactory.CreateLogger<TableInfo>();
+
+ _logger.LogInformation("Create TableInfo for entity type '{}'.", entityType.Name);
+
+ _columnTypeProvider = columnTypeProvider;
+
+ TableName = tableName;
+ EntityType = entityType;
+
+
+ var properties = entityType.GetProperties();
+ _logger.LogInformation("Find following properties: {}", string.Join(", ", properties.Select(p => p.Name)));
+
+ var columnInfos = new List<ColumnInfo>();
+
+ bool hasId = false;
+ ColumnInfo? primaryKeyColumn = null;
+ ColumnInfo? keyColumn = null;
+
+ List<PropertyInfo> nonColumnProperties = new();
+
+ foreach (var property in properties)
+ {
+ _logger.LogInformation("Check property '{}'.", property.Name);
+ if (CheckPropertyIsColumn(property))
+ {
+ _logger.LogInformation("{} is a column, create ColumnInfo for it.", property.Name);
+ var columnInfo = new ColumnInfo(this, property, _columnTypeProvider, _loggerFactory);
+ columnInfos.Add(columnInfo);
+ if (columnInfo.IsPrimaryKey)
+ {
+ _logger.LogInformation("Column {} is a primary key.", property.Name);
+ primaryKeyColumn = columnInfo;
+ }
+ if (columnInfo.ColumnName.Equals("id", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Column {} has name id.", property.Name);
+ hasId = true;
+ }
+ if (columnInfo.IsSpecifiedAsKey)
+ {
+ if (keyColumn is not null)
+ {
+ throw new Exception("Already exists a key column.");
+ }
+ _logger.LogInformation("Column {} is specified as key.", property.Name);
+ keyColumn = columnInfo;
+ }
+ }
+ else
+ {
+ _logger.LogInformation("{} is not a column.", property.Name);
+ nonColumnProperties.Add(property);
+ }
+ }
+
+ if (primaryKeyColumn is null)
+ {
+ if (hasId) throw new Exception("A column named id already exists but is not primary key.");
+ _logger.LogInformation("No primary key column found, create one automatically.");
+ primaryKeyColumn = CreateAutoIdColumn();
+ columnInfos.Add(primaryKeyColumn);
+ }
+
+ if (keyColumn is null)
+ {
+ _logger.LogInformation("No key column is specified, will use primary key.");
+ keyColumn = primaryKeyColumn;
+ }
+
+ Columns = columnInfos;
+ PrimaryKeyColumn = primaryKeyColumn;
+ KeyColumn = keyColumn;
+ NonColumnProperties = nonColumnProperties;
+
+ _logger.LogInformation("Check table validity.");
+ CheckValidity();
+
+ _logger.LogInformation("TableInfo succeeded to create.");
+
+ _lazyColumnNameList = new Lazy<List<string>>(() => Columns.Select(c => c.ColumnName).ToList());
+ }
+
+ private ColumnInfo CreateAutoIdColumn()
+ {
+ return new ColumnInfo(this,
+ new ColumnAttribute
+ {
+ ColumnName = "Id",
+ NotNull = true,
+ IsPrimaryKey = true,
+ },
+ typeof(long), _columnTypeProvider, _loggerFactory);
+ }
+
+ public Type EntityType { get; }
+ public string TableName { get; }
+ public IReadOnlyList<ColumnInfo> Columns { get; }
+ public IReadOnlyList<ColumnInfo> PropertyColumns => Columns.Where(c => c.PropertyInfo is not null).ToList();
+ public ColumnInfo PrimaryKeyColumn { get; }
+ /// <summary>
+ /// Maybe not the primary key. But acts as primary key.
+ /// </summary>
+ /// <seealso cref="ColumnMetadataKeys.ActAsKey"/>
+ public ColumnInfo KeyColumn { get; }
+ public IReadOnlyList<PropertyInfo> ColumnProperties => PropertyColumns.Select(c => c.PropertyInfo!).ToList();
+ public IReadOnlyList<PropertyInfo> NonColumnProperties { get; }
+ public IReadOnlyList<string> ColumnNameList => _lazyColumnNameList.Value;
+
+ protected bool CheckPropertyIsColumn(PropertyInfo property)
+ {
+ var columnAttribute = property.GetCustomAttribute<ColumnAttribute>();
+ if (columnAttribute is null) return false;
+ return true;
+ }
+
+ public ColumnInfo GetColumn(string columnName)
+ {
+ foreach (var column in Columns)
+ {
+ if (column.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase))
+ {
+ return column;
+ }
+ }
+ throw new KeyNotFoundException("No such column with given name.");
+ }
+
+ public void CheckGeneratedColumnHasGenerator()
+ {
+ foreach (var column in Columns)
+ {
+ if (column.IsOnlyGenerated && column.DefaultValueGeneratorMethod is null)
+ {
+ throw new Exception($"Column '{column.ColumnName}' is generated but has no generator.");
+ }
+ }
+ }
+
+ public void CheckValidity()
+ {
+ // Check if there is only one primary key.
+ bool hasPrimaryKey = false;
+ bool hasKey = false;
+ foreach (var column in Columns)
+ {
+ if (column.IsPrimaryKey)
+ {
+ if (hasPrimaryKey) throw new Exception("More than one columns are primary key.");
+ hasPrimaryKey = true;
+ }
+
+ if (column.IsSpecifiedAsKey)
+ {
+ if (hasKey) throw new Exception("More than one columns are specified as key column.");
+ }
+ }
+
+ if (!hasPrimaryKey) throw new Exception("No column is primary key.");
+
+ // Check two columns have the same sql name.
+ HashSet<string> sqlNameSet = new HashSet<string>();
+
+ foreach (var column in Columns)
+ {
+ if (sqlNameSet.Contains(column.ColumnName))
+ throw new Exception($"Two columns have the same sql name '{column.ColumnName}'.");
+ sqlNameSet.Add(column.ColumnName);
+ }
+
+ CheckGeneratedColumnHasGenerator();
+ }
+
+ public string GenerateCreateIndexSql(string? dbProviderId = null)
+ {
+ var sb = new StringBuilder();
+
+ foreach (var column in Columns)
+ {
+ if (column.Index == ColumnIndexType.None) continue;
+
+ sb.Append($"CREATE {(column.Index == ColumnIndexType.Unique ? "UNIQUE" : "")} INDEX {TableName}_{column.ColumnName}_index ON {TableName} ({column.ColumnName});\n");
+ }
+
+ return sb.ToString();
+ }
+
+ public string GenerateCreateTableSql(bool createIndex = true, string? dbProviderId = null)
+ {
+ var tableName = TableName;
+ var columnSql = string.Join(",\n", Columns.Select(c => c.GenerateCreateTableColumnString(dbProviderId)));
+
+ var sql = $@"
+CREATE TABLE {tableName}(
+ {columnSql}
+);
+ ";
+
+ if (createIndex)
+ {
+ sql += GenerateCreateIndexSql(dbProviderId);
+ }
+
+ return sql;
+ }
+
+ public void CheckColumnName(string columnName)
+ {
+ if (!ColumnNameList.Contains(columnName))
+ {
+ throw new ArgumentException($"Column {columnName} is not in the table.");
+ }
+ }
+
+ public void CheckRelatedColumns(IClause? clause)
+ {
+ if (clause is not null)
+ {
+ var relatedColumns = clause.GetRelatedColumns();
+ foreach (var column in relatedColumns)
+ {
+ CheckColumnName(column);
+ }
+ }
+ }
+
+ /// <summary>
+ /// If you call this manually, it's your duty to call hooks.
+ /// </summary>
+ /// <seealso cref="SelectDynamic"/>
+ public (string sql, ParamList parameters) GenerateSelectSql(string? selectWhat, IWhereClause? whereClause, IOrderByClause? orderByClause = null, int? skip = null, int? limit = null, string? dbProviderId = null)
+ {
+ CheckRelatedColumns(whereClause);
+ CheckRelatedColumns(orderByClause);
+
+ var parameters = new ParamList();
+
+ StringBuilder result = new StringBuilder()
+ .Append($"SELECT {selectWhat ?? "*"} FROM ")
+ .Append(TableName);
+
+ if (whereClause is not null)
+ {
+ result.Append(" WHERE ");
+ var (whereSql, whereParameters) = whereClause.GenerateSql(dbProviderId);
+ parameters.AddRange(whereParameters);
+ result.Append(whereSql);
+ }
+
+ if (orderByClause is not null)
+ {
+ result.Append(' ');
+ var orderBySql = orderByClause.GenerateSql(dbProviderId);
+ result.Append(orderBySql);
+ }
+
+ if (limit is not null)
+ {
+ result.Append(" LIMIT @Limit");
+ parameters.Add("Limit", limit.Value);
+ }
+
+ if (skip is not null)
+ {
+ result.Append(" OFFSET @Skip");
+ parameters.Add("Skip", skip.Value);
+ }
+
+ result.Append(';');
+
+ return (result.ToString(), parameters);
+ }
+
+ /// <summary>
+ /// If you call this manually, it's your duty to call hooks.
+ /// </summary>
+ /// <seealso cref="Insert"/>
+ public (string sql, ParamList parameters) GenerateInsertSql(IInsertClause insertClause, string? dbProviderId = null)
+ {
+ CheckRelatedColumns(insertClause);
+
+ var parameters = new ParamList();
+
+ var result = new StringBuilder()
+ .Append("INSERT INTO ")
+ .Append(TableName)
+ .Append(" (")
+ .Append(insertClause.GenerateColumnListSql(dbProviderId))
+ .Append(") VALUES (");
+
+ var (valueSql, valueParameters) = insertClause.GenerateValueListSql(dbProviderId);
+ result.Append(valueSql).Append(");");
+
+ parameters.AddRange(valueParameters);
+
+ return (result.ToString(), parameters);
+ }
+
+ /// <summary>
+ /// If you call this manually, it's your duty to call hooks.
+ /// </summary>
+ /// <seealso cref="Update"/>
+ public (string sql, ParamList parameters) GenerateUpdateSql(IWhereClause? whereClause, IUpdateClause updateClause)
+ {
+ CheckRelatedColumns(whereClause);
+ CheckRelatedColumns(updateClause);
+
+ var parameters = new ParamList();
+
+ StringBuilder sb = new StringBuilder("UPDATE ");
+ sb.Append(TableName);
+ sb.Append(" SET ");
+ var (updateSql, updateParameters) = updateClause.GenerateSql();
+ sb.Append(updateSql);
+ parameters.AddRange(updateParameters);
+ if (whereClause is not null)
+ {
+ sb.Append(" WHERE ");
+ var (whereSql, whereParameters) = whereClause.GenerateSql();
+ sb.Append(whereSql);
+ parameters.AddRange(whereParameters);
+ }
+ sb.Append(';');
+
+ return (sb.ToString(), parameters);
+ }
+
+ /// <summary>
+ /// If you call this manually, it's your duty to call hooks.
+ /// </summary>
+ /// <seealso cref="Delete"/>
+ public (string sql, ParamList parameters) GenerateDeleteSql(IWhereClause? whereClause)
+ {
+ CheckRelatedColumns(whereClause);
+
+ var parameters = new ParamList();
+
+ StringBuilder sb = new StringBuilder("DELETE FROM ");
+ sb.Append(TableName);
+ if (whereClause is not null)
+ {
+ sb.Append(" WHERE ");
+ var (whereSql, whereParameters) = whereClause.GenerateSql();
+ parameters.AddRange(whereParameters);
+ sb.Append(whereSql);
+ }
+ sb.Append(';');
+
+ return (sb.ToString(), parameters);
+ }
+
+ private DynamicParameters ConvertParameters(ParamList parameters)
+ {
+ var result = new DynamicParameters();
+ foreach (var param in parameters)
+ {
+ if (param.Value is null || param.Value is DbNullValue)
+ {
+ result.Add(param.Name, null);
+ continue;
+ }
+
+ var columnName = param.ColumnName;
+ IColumnTypeInfo typeInfo;
+ if (columnName is not null)
+ {
+ typeInfo = GetColumn(columnName).ColumnType;
+ }
+ else
+ {
+ typeInfo = _columnTypeProvider.Get(param.Value.GetType());
+ }
+
+ result.Add(param.Name, typeInfo.ConvertToDatabase(param.Value), typeInfo.DbType);
+ }
+ return result;
+ }
+
+ /// <summary>
+ /// ConvertParameters. Select. Call hooks.
+ /// </summary>
+ public virtual List<dynamic> SelectDynamic(IDbConnection dbConnection, string? what = null, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null)
+ {
+ var (sql, parameters) = GenerateSelectSql(what, where, orderBy, skip, limit);
+ var queryResult = dbConnection.Query<dynamic>(sql, ConvertParameters(parameters));
+ return queryResult.ToList();
+ }
+
+ public virtual int SelectCount(IDbConnection dbConnection, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null)
+ {
+ var (sql, parameters) = GenerateSelectSql("COUNT(*)", where, orderBy, skip, limit);
+ var result = dbConnection.QuerySingle<int>(sql, ConvertParameters(parameters));
+ return result;
+ }
+
+ public virtual TResult MapDynamicTo<TResult>(dynamic d)
+ {
+ var dict = (IDictionary<string, object?>)d;
+
+ var result = Activator.CreateInstance<TResult>();
+ Type resultType = typeof(TResult);
+
+ foreach (var column in Columns)
+ {
+ var resultProperty = resultType.GetProperty(column.ColumnName);
+ if (dict.ContainsKey(column.ColumnName) && resultProperty is not null)
+ {
+ if (dict[column.ColumnName] is null)
+ {
+ resultProperty.SetValue(result, null);
+ continue;
+ }
+ object? value = Convert.ChangeType(dict[column.ColumnName], column.ColumnType.DatabaseClrType);
+ value = column.ColumnType.ConvertFromDatabase(value);
+ resultProperty.SetValue(result, value);
+ }
+ }
+
+ return result;
+ }
+
+ /// <summary>
+ /// Select and call hooks.
+ /// </summary>
+ public virtual List<TResult> Select<TResult>(IDbConnection dbConnection, string? what = null, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null)
+ {
+ List<dynamic> queryResult = SelectDynamic(dbConnection, what, where, orderBy, skip, limit).ToList();
+
+ return queryResult.Select(MapDynamicTo<TResult>).ToList();
+ }
+
+ public IInsertClause ConvertEntityToInsertClause(object entity)
+ {
+ Debug.Assert(EntityType.IsInstanceOfType(entity));
+ var result = new InsertClause();
+ foreach (var column in PropertyColumns)
+ {
+ var value = column.PropertyInfo!.GetValue(entity);
+ result.Add(column.ColumnName, value);
+ }
+ return result;
+ }
+
+ /// <summary>
+ /// Insert a entity and call hooks.
+ /// </summary>
+ /// <returns>The key of insert entity.</returns>
+ public int Insert(IDbConnection dbConnection, IInsertClause insert, out object key)
+ {
+ object? finalKey = null;
+
+ var realInsert = InsertClause.Create();
+
+ foreach (var column in Columns)
+ {
+ InsertItem? item = insert.Items.SingleOrDefault(i => i.ColumnName == column.ColumnName);
+
+ var value = item?.Value;
+
+ if (column.IsOnlyGenerated && value is not null)
+ {
+ throw new Exception($"The column '{column.ColumnName}' is auto generated. You can't specify it explicitly.");
+ }
+
+ if (value is null)
+ {
+ value = column.GenerateDefaultValue();
+ }
+
+ if (value is null && column.IsAutoIncrement)
+ {
+ continue;
+ }
+
+ if (value is null)
+ {
+ value = DbNullValue.Instance;
+ }
+
+ column.InvokeValidator(value);
+
+ InsertItem realInsertItem;
+
+ if (value is DbNullValue)
+ {
+ if (column.IsNotNull)
+ {
+ throw new Exception($"Column '{column.ColumnName}' is not nullable. Please specify a non-null value.");
+ }
+
+ realInsertItem = new InsertItem(column.ColumnName, null);
+ }
+ else
+ {
+ realInsertItem = new InsertItem(column.ColumnName, value);
+ }
+
+ realInsert.Add(realInsertItem);
+
+ if (realInsertItem.ColumnName == KeyColumn.ColumnName)
+ {
+ finalKey = realInsertItem.Value;
+ }
+ }
+
+ if (finalKey is null) throw new Exception("No key???");
+ key = finalKey;
+
+ var (sql, parameters) = GenerateInsertSql(realInsert);
+
+ var affectedRowCount = dbConnection.Execute(sql, ConvertParameters(parameters));
+
+ if (affectedRowCount != 1)
+ throw new Exception("Failed to insert.");
+
+ return affectedRowCount;
+ }
+
+ /// <summary>
+ /// Upgrade a entity and call hooks.
+ /// </summary>
+ /// <returns>The key of insert entity.</returns>
+ public virtual int Update(IDbConnection dbConnection, IWhereClause? where, IUpdateClause update, out object? newKey)
+ {
+ newKey = null;
+
+ var realUpdate = UpdateClause.Create();
+
+ foreach (var column in Columns)
+ {
+ UpdateItem? item = update.Items.FirstOrDefault(i => i.ColumnName == column.ColumnName);
+ object? value = item?.Value;
+
+ if (value is not null)
+ {
+ if (column.IsNoUpdate)
+ {
+ throw new Exception($"The column '{column.ColumnName}' can't be update.");
+ }
+
+ column.InvokeValidator(value);
+
+ realUpdate.Add(column.ColumnName, value);
+
+ if (column.ColumnName == KeyColumn.ColumnName)
+ {
+ newKey = value;
+ }
+ }
+ }
+
+ var (sql, parameters) = GenerateUpdateSql(where, realUpdate);
+ return dbConnection.Execute(sql, ConvertParameters(parameters));
+ }
+
+ public virtual int Delete(IDbConnection dbConnection, IWhereClause? where)
+ {
+ var (sql, parameters) = GenerateDeleteSql(where);
+ return dbConnection.Execute(sql, ConvertParameters(parameters));
+ }
+}
+
+public interface ITableInfoFactory
+{
+ TableInfo Get(Type type);
+}
+
+public class TableInfoFactory : ITableInfoFactory
+{
+ private readonly Dictionary<Type, TableInfo> _cache = new Dictionary<Type, TableInfo>();
+ private readonly IColumnTypeProvider _columnTypeProvider;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ILogger<TableInfoFactory> _logger;
+
+ public TableInfoFactory(IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory)
+ {
+ _columnTypeProvider = columnTypeProvider;
+ _loggerFactory = loggerFactory;
+ _logger = loggerFactory.CreateLogger<TableInfoFactory>();
+ }
+
+ // This is thread-safe.
+ public TableInfo Get(Type type)
+ {
+ lock (_cache)
+ {
+ if (_cache.TryGetValue(type, out var tableInfo))
+ {
+ _logger.LogDebug("Table info of type '{}' is cached, return it.", type.Name);
+ return tableInfo;
+ }
+ else
+ {
+ _logger.LogDebug("Table info for type '{}' is not in cache, create it.", type.Name);
+ tableInfo = new TableInfo(type, _columnTypeProvider, _loggerFactory);
+ _logger.LogDebug("Table info for type '{}' is created, add it to cache.", type.Name);
+ _cache.Add(type, tableInfo);
+ return tableInfo;
+ }
+ }
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UpdateClause.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UpdateClause.cs
new file mode 100644
index 0000000..de5c6c3
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UpdateClause.cs
@@ -0,0 +1,77 @@
+using System.Text;
+
+namespace CrupestApi.Commons.Crud;
+
+public class UpdateItem
+{
+ public UpdateItem(string columnName, object? value)
+ {
+ ColumnName = columnName;
+ Value = value;
+ }
+
+ public string ColumnName { get; set; }
+ public object? Value { get; set; }
+}
+
+public interface IUpdateClause : IClause
+{
+ List<UpdateItem> Items { get; }
+ (string sql, ParamList parameters) GenerateSql();
+}
+
+public class UpdateClause : IUpdateClause
+{
+ public List<UpdateItem> Items { get; } = new List<UpdateItem>();
+
+ public UpdateClause(IEnumerable<UpdateItem> items)
+ {
+ Items.AddRange(items);
+ }
+
+ public UpdateClause(params UpdateItem[] items)
+ {
+ Items.AddRange(items);
+ }
+
+ public UpdateClause Add(params UpdateItem[] items)
+ {
+ Items.AddRange(items);
+ return this;
+ }
+
+ public UpdateClause Add(string column, object? value)
+ {
+ return Add(new UpdateItem(column, value));
+ }
+
+ public static UpdateClause Create(params UpdateItem[] items)
+ {
+ return new UpdateClause(items);
+ }
+
+ public List<string> GetRelatedColumns()
+ {
+ return Items.Select(i => i.ColumnName).ToList();
+ }
+
+ public (string sql, ParamList parameters) GenerateSql()
+ {
+ var parameters = new ParamList();
+
+ StringBuilder result = new StringBuilder();
+
+ foreach (var item in Items)
+ {
+ if (result.Length > 0)
+ {
+ result.Append(", ");
+ }
+
+ var parameterName = parameters.AddRandomNameParameter(item.Value, item.ColumnName);
+ result.Append($"{item.ColumnName} = @{parameterName}");
+ }
+
+ return (result.ToString(), parameters);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UserException.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UserException.cs
new file mode 100644
index 0000000..1a10b97
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/UserException.cs
@@ -0,0 +1,15 @@
+namespace CrupestApi.Commons.Crud;
+
+/// <summary>
+/// This exception means the exception is caused by user and can be safely shown to user.
+/// </summary>
+[System.Serializable]
+public class UserException : Exception
+{
+ public UserException() { }
+ public UserException(string message) : base(message) { }
+ public UserException(string message, System.Exception inner) : base(message, inner) { }
+ protected UserException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/WhereClause.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/WhereClause.cs
new file mode 100644
index 0000000..de69f2f
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/WhereClause.cs
@@ -0,0 +1,182 @@
+using System.Text;
+
+namespace CrupestApi.Commons.Crud;
+
+public interface IWhereClause : IClause
+{
+ // Does not contain "WHERE" keyword!
+ (string sql, ParamList parameters) GenerateSql(string? dbProviderId = null);
+}
+
+public class CompositeWhereClause : IWhereClause
+{
+ public CompositeWhereClause(string concatOp, bool parenthesesSubclause, params IWhereClause[] subclauses)
+ {
+ ConcatOp = concatOp;
+ ParenthesesSubclause = parenthesesSubclause;
+ Subclauses = subclauses.ToList();
+ }
+
+ public string ConcatOp { get; }
+ public bool ParenthesesSubclause { get; }
+ public List<IWhereClause> Subclauses { get; }
+
+ public CompositeWhereClause Eq(string column, object? value)
+ {
+ Subclauses.Add(SimpleCompareWhereClause.Eq(column, value));
+ return this;
+ }
+
+ public (string sql, ParamList parameters) GenerateSql(string? dbProviderId = null)
+ {
+ var parameters = new ParamList();
+ var sql = new StringBuilder();
+ var subclauses = GetSubclauses();
+ if (subclauses is null) return ("", new());
+ var first = true;
+ foreach (var subclause in Subclauses)
+ {
+ var (subSql, subParameters) = subclause.GenerateSql(dbProviderId);
+ if (subSql is null) continue;
+ if (first)
+ {
+ first = false;
+ }
+ else
+ {
+ sql.Append($" {ConcatOp} ");
+ }
+ if (ParenthesesSubclause)
+ {
+ sql.Append("(");
+ }
+ sql.Append(subSql);
+ if (ParenthesesSubclause)
+ {
+ sql.Append(")");
+ }
+ parameters.AddRange(subParameters);
+ }
+ return (sql.ToString(), parameters);
+ }
+
+ public object GetSubclauses()
+ {
+ return Subclauses;
+ }
+}
+
+public class AndWhereClause : CompositeWhereClause
+{
+ public AndWhereClause(params IWhereClause[] clauses)
+ : this(true, clauses)
+ {
+
+ }
+
+ public AndWhereClause(bool parenthesesSubclause, params IWhereClause[] clauses)
+ : base("AND", parenthesesSubclause, clauses)
+ {
+
+ }
+
+ public static AndWhereClause Create(params IWhereClause[] clauses)
+ {
+ return new AndWhereClause(clauses);
+ }
+}
+
+public class OrWhereClause : CompositeWhereClause
+{
+ public OrWhereClause(params IWhereClause[] clauses)
+ : this(true, clauses)
+ {
+
+ }
+
+ public OrWhereClause(bool parenthesesSubclause, params IWhereClause[] clauses)
+ : base("OR", parenthesesSubclause, clauses)
+ {
+
+ }
+
+ public static OrWhereClause Create(params IWhereClause[] clauses)
+ {
+ return new OrWhereClause(clauses);
+ }
+}
+
+// It's simple because it only compare column and value but not expressions.
+public class SimpleCompareWhereClause : IWhereClause
+{
+ public string Column { get; }
+ public string Operator { get; }
+ public object? Value { get; }
+
+ public List<string> GetRelatedColumns()
+ {
+ return new List<string> { Column };
+ }
+
+ // It's user's responsibility to keep column safe, with proper escape.
+ public SimpleCompareWhereClause(string column, string op, object? value)
+ {
+ Column = column;
+ Operator = op;
+ Value = value;
+ }
+
+ public static SimpleCompareWhereClause Create(string column, string op, object? value)
+ {
+ return new SimpleCompareWhereClause(column, op, value);
+ }
+
+ public static SimpleCompareWhereClause Eq(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, "=", value);
+ }
+
+ public static SimpleCompareWhereClause Neq(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, "<>", value);
+ }
+
+ public static SimpleCompareWhereClause Gt(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, ">", value);
+ }
+
+ public static SimpleCompareWhereClause Gte(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, ">=", value);
+ }
+
+ public static SimpleCompareWhereClause Lt(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, "<", value);
+ }
+
+ public static SimpleCompareWhereClause Lte(string column, object? value)
+ {
+ return new SimpleCompareWhereClause(column, "<=", value);
+ }
+
+ public (string sql, ParamList parameters) GenerateSql(string? dbProviderId = null)
+ {
+ var parameters = new ParamList();
+ var parameterName = parameters.AddRandomNameParameter(Value, Column);
+ return ($"{Column} {Operator} @{parameterName}", parameters);
+ }
+}
+
+public class WhereClause : AndWhereClause
+{
+ public WhereClause()
+ {
+ }
+
+ public void Add(IWhereClause subclause)
+ {
+ Subclauses.Add(subclause);
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/CrupestApi.Commons.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/CrupestApi.Commons.csproj
new file mode 100644
index 0000000..8e291fa
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/CrupestApi.Commons.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <TargetType>library</TargetType>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <SelfContained>false</SelfContained>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Dapper" Version="2.0.123" />
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="7.0.0" />
+ </ItemGroup>
+
+</Project> \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs
new file mode 100644
index 0000000..0e1f4f4
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs
@@ -0,0 +1,8 @@
+namespace CrupestApi.Commons;
+
+public class EntityNotExistException : Exception
+{
+ public EntityNotExistException() { }
+ public EntityNotExistException(string message) : base(message) { }
+ public EntityNotExistException(string message, Exception inner) : base(message, inner) { }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpContextExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpContextExtensions.cs
new file mode 100644
index 0000000..a0b2d89
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpContextExtensions.cs
@@ -0,0 +1,113 @@
+using System.Text.Json;
+using CrupestApi.Commons.Secrets;
+using Microsoft.Extensions.Options;
+
+namespace CrupestApi.Commons;
+
+public delegate void HttpResponseAction(HttpResponse response);
+
+public class MessageBody
+{
+ public MessageBody(string message)
+ {
+ Message = message;
+ }
+
+ public string Message { get; set; }
+}
+
+public static class CrupestApiJsonExtensions
+{
+ public static IServiceCollection AddJsonOptions(this IServiceCollection services)
+ {
+ services.AddOptions<JsonSerializerOptions>();
+ services.Configure<JsonSerializerOptions>(config =>
+ {
+ config.AllowTrailingCommas = true;
+ config.PropertyNameCaseInsensitive = true;
+ config.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ });
+
+ return services;
+ }
+
+ public static async Task<JsonDocument> ReadJsonAsync(this HttpRequest request)
+ {
+ var jsonOptions = request.HttpContext.RequestServices.GetRequiredService<IOptionsSnapshot<JsonSerializerOptions>>();
+ using var stream = request.Body;
+ var body = await JsonSerializer.DeserializeAsync<JsonDocument>(stream, jsonOptions.Value);
+ return body!;
+ }
+
+ public static async Task WriteJsonAsync<T>(this HttpResponse response, T bodyObject, int statusCode = 200, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default)
+ {
+ var jsonOptions = response.HttpContext.RequestServices.GetRequiredService<IOptionsSnapshot<JsonSerializerOptions>>();
+ byte[] json = JsonSerializer.SerializeToUtf8Bytes<T>(bodyObject, jsonOptions.Value);
+
+ var byteCount = json.Length;
+
+ response.StatusCode = statusCode;
+ response.Headers.ContentType = "application/json; charset=utf-8";
+ response.Headers.ContentLength = byteCount;
+
+ if (beforeWriteBody is not null)
+ {
+ beforeWriteBody(response);
+ }
+
+ await response.Body.WriteAsync(json, cancellationToken);
+ }
+
+ public static async Task WriteMessageAsync(this HttpResponse response, string message, int statusCode = 400, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default)
+ {
+ await response.WriteJsonAsync(new MessageBody(message), statusCode: statusCode, beforeWriteBody, cancellationToken);
+ }
+
+ public static Task ResponseJsonAsync<T>(this HttpContext context, T bodyObject, int statusCode = 200, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default)
+ {
+ return context.Response.WriteJsonAsync<T>(bodyObject, statusCode, beforeWriteBody, cancellationToken);
+ }
+
+ public static Task ResponseMessageAsync(this HttpContext context, string message, int statusCode = 400, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default)
+ {
+ return context.Response.WriteMessageAsync(message, statusCode, beforeWriteBody, cancellationToken);
+ }
+
+ public static string? GetToken(this HttpRequest request)
+ {
+ var token = request.Headers["Authorization"].ToString();
+ if (token.StartsWith("Bearer "))
+ {
+ token = token.Substring("Bearer ".Length);
+ return token;
+ }
+
+ if (request.Query.TryGetValue("token", out var tokenValues))
+ {
+ return tokenValues.Last();
+ }
+
+ return null;
+ }
+
+ public static bool RequirePermission(this HttpContext context, string? permission)
+ {
+ if (permission is null) return true;
+
+ var token = context.Request.GetToken();
+ if (token is null)
+ {
+ context.ResponseMessageAsync("Unauthorized", 401);
+ return false;
+ }
+
+ var secretService = context.RequestServices.GetRequiredService<ISecretService>();
+ var permissions = secretService.GetPermissions(token);
+ if (!permissions.Contains(permission))
+ {
+ context.ResponseMessageAsync("Forbidden", 403);
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/ISecretService.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/ISecretService.cs
new file mode 100644
index 0000000..83025f8
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/ISecretService.cs
@@ -0,0 +1,8 @@
+namespace CrupestApi.Commons.Secrets;
+
+public interface ISecretService
+{
+ void CreateTestSecret(string key, string secret);
+
+ List<string> GetPermissions(string secret);
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretInfo.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretInfo.cs
new file mode 100644
index 0000000..c3a4de0
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretInfo.cs
@@ -0,0 +1,48 @@
+using System.Security.Cryptography;
+using System.Text;
+using CrupestApi.Commons.Crud;
+
+namespace CrupestApi.Commons.Secrets;
+
+public class SecretInfo
+{
+ [Column(NotNull = true)]
+ public string Key { get; set; } = default!;
+ [Column(NotNull = true, NoUpdate = true, ActAsKey = true)]
+ public string Secret { get; set; } = default!;
+ [Column(DefaultEmptyForString = true)]
+ public string Description { get; set; } = default!;
+ [Column(NotNull = false)]
+ public DateTime? ExpireTime { get; set; }
+ [Column(NotNull = true, DefaultValue = false)]
+ public bool Revoked { get; set; }
+ [Column(NotNull = true)]
+ public DateTime CreateTime { get; set; }
+
+ private static RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create();
+
+ private static string GenerateRandomKey(int length)
+ {
+ const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ var result = new StringBuilder(length);
+ lock (RandomNumberGenerator)
+ {
+ for (int i = 0; i < length; i++)
+ {
+ result.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
+ }
+ }
+ return result.ToString();
+ }
+
+
+ public static string SecretDefaultValueGenerator()
+ {
+ return GenerateRandomKey(16);
+ }
+
+ public static DateTime CreateTimeDefaultValueGenerator()
+ {
+ return DateTime.UtcNow;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs
new file mode 100644
index 0000000..c693d8d
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs
@@ -0,0 +1,48 @@
+using System.Data;
+using CrupestApi.Commons.Crud;
+using CrupestApi.Commons.Crud.Migrations;
+
+namespace CrupestApi.Commons.Secrets;
+
+public class SecretService : CrudService<SecretInfo>, ISecretService
+{
+ private readonly ILogger<SecretService> _logger;
+
+ public SecretService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, IDatabaseMigrator migrator, ILoggerFactory loggerFactory)
+ : base(tableInfoFactory, dbConnectionFactory, migrator, loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger<SecretService>();
+ }
+
+ protected override void AfterMigrate(IDbConnection connection, TableInfo table)
+ {
+ if (table.SelectCount(connection) == 0)
+ {
+ _logger.LogInformation("No secrets found, insert default secrets.");
+ using var transaction = connection.BeginTransaction();
+ var insertClause = InsertClause.Create()
+ .Add(nameof(SecretInfo.Key), SecretsConstants.SecretManagementKey)
+ .Add(nameof(SecretInfo.Secret), "crupest")
+ .Add(nameof(SecretInfo.Description), "This is the init key. Please revoke it immediately after creating a new one.");
+ _table.Insert(connection, insertClause, out var _);
+ transaction.Commit();
+ }
+ }
+
+ public void CreateTestSecret(string key, string secret)
+ {
+ var connection = _dbConnection;
+ var insertClause = InsertClause.Create()
+ .Add(nameof(SecretInfo.Key), key)
+ .Add(nameof(SecretInfo.Secret), secret)
+ .Add(nameof(SecretInfo.Description), "Test secret.");
+ _table.Insert(connection, insertClause, out var _);
+ }
+
+ public List<string> GetPermissions(string secret)
+ {
+ var list = _table.Select<SecretInfo>(_dbConnection,
+ where: WhereClause.Create().Eq(nameof(SecretInfo.Secret), secret));
+ return list.Select(x => x.Key).ToList();
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretServiceCollectionExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretServiceCollectionExtensions.cs
new file mode 100644
index 0000000..a9c0e5f
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CrupestApi.Commons.Secrets;
+
+public static class SecretServiceCollectionExtensions
+{
+ public static IServiceCollection AddSecrets(this IServiceCollection services)
+ {
+ services.TryAddScoped<ISecretService, SecretService>();
+ return services;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretsConstants.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretsConstants.cs
new file mode 100644
index 0000000..207cc45
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretsConstants.cs
@@ -0,0 +1,6 @@
+namespace CrupestApi.Commons.Secrets;
+
+public static class SecretsConstants
+{
+ public const string SecretManagementKey = "crupest.secrets.management";
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/CrupestApi.Files.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/CrupestApi.Files.csproj
new file mode 100644
index 0000000..2221809
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/CrupestApi.Files.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <ItemGroup>
+ <ProjectReference Include="..\CrupestApi.Commons\CrupestApi.Commons.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Dapper" Version="2.0.123" />
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="7.0.0" />
+ </ItemGroup>
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <TargetType>library</TargetType>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <SelfContained>false</SelfContained>
+ </PropertyGroup>
+
+</Project>
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/FilesService.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/FilesService.cs
new file mode 100644
index 0000000..c851a92
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Files/FilesService.cs
@@ -0,0 +1,6 @@
+namespace CrupestApi.Files;
+
+public class FilesService
+{
+
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/CrupestApi.Secrets.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/CrupestApi.Secrets.csproj
new file mode 100644
index 0000000..70c83f3
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/CrupestApi.Secrets.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <ItemGroup>
+ <ProjectReference Include="..\CrupestApi.Commons\CrupestApi.Commons.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Dapper" Version="2.0.123" />
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="7.0.0" />
+ </ItemGroup>
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <TargetType>library</TargetType>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <SelfContained>false</SelfContained>
+ </PropertyGroup>
+
+</Project>
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsExtensions.cs
new file mode 100644
index 0000000..e09887b
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsExtensions.cs
@@ -0,0 +1,19 @@
+using CrupestApi.Commons.Secrets;
+using CrupestApi.Commons.Crud;
+
+namespace CrupestApi.Secrets;
+
+public static class SecretsExtensions
+{
+ public static IServiceCollection AddSecrets(this IServiceCollection services)
+ {
+ services.AddCrud<SecretInfo, SecretService>();
+ return services;
+ }
+
+ public static WebApplication MapSecrets(this WebApplication webApplication, string path = "/api/secrets")
+ {
+ webApplication.MapCrud<SecretInfo>(path, SecretsConstants.SecretManagementKey);
+ return webApplication;
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/CrupestApi.Todos.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/CrupestApi.Todos.csproj
new file mode 100644
index 0000000..86460e3
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/CrupestApi.Todos.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <ItemGroup>
+ <ProjectReference Include="..\CrupestApi.Commons\CrupestApi.Commons.csproj" />
+ </ItemGroup>
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <TargetType>library</TargetType>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <SelfContained>false</SelfContained>
+ </PropertyGroup>
+
+</Project>
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosConfiguration.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosConfiguration.cs
new file mode 100644
index 0000000..e8160d2
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosConfiguration.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CrupestApi.Todos;
+
+public class TodosConfiguration
+{
+ [Required]
+ public string Username { get; set; } = default!;
+ [Required]
+ public int ProjectNumber { get; set; } = default!;
+ [Required]
+ public string Token { get; set; } = default!;
+ public int Count { get; set; }
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosService.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosService.cs
new file mode 100644
index 0000000..5839086
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosService.cs
@@ -0,0 +1,163 @@
+using System.Net.Http.Headers;
+using System.Net.Mime;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+
+namespace CrupestApi.Todos;
+
+public class TodosItem
+{
+ public string Status { get; set; } = default!;
+ public string Title { get; set; } = default!;
+ public bool Closed { get; set; }
+ public string Color { get; set; } = default!;
+}
+
+public class TodosService
+{
+ private readonly IOptionsSnapshot<TodosConfiguration> _options;
+ private readonly ILogger<TodosService> _logger;
+
+ public TodosService(IOptionsSnapshot<TodosConfiguration> options, ILogger<TodosService> logger)
+ {
+ _options = options;
+ _logger = logger;
+ }
+
+ private static string CreateGraphQLQuery(TodosConfiguration todoConfiguration)
+ {
+ return $$"""
+{
+ user(login: "{{todoConfiguration.Username}}") {
+ projectV2(number: {{todoConfiguration.ProjectNumber}}) {
+ items(last: {{todoConfiguration.Count}}) {
+ nodes {
+ fieldValueByName(name: "Status") {
+ ... on ProjectV2ItemFieldSingleSelectValue {
+ name
+ }
+ }
+ content {
+ __typename
+ ... on Issue {
+ title
+ closed
+ }
+ ... on PullRequest {
+ title
+ closed
+ }
+ ... on DraftIssue {
+ title
+ }
+ }
+ }
+ }
+ }
+ }
+}
+""";
+ }
+
+
+ public async Task<List<TodosItem>> GetTodosAsync()
+ {
+ var todoOptions = _options.Value;
+ if (todoOptions is null)
+ {
+ throw new Exception("Fail to get todos configuration.");
+ }
+
+ _logger.LogInformation("Username: {}; ProjectNumber: {}; Count: {}", todoOptions.Username, todoOptions.ProjectNumber, todoOptions.Count);
+ _logger.LogInformation("Getting todos from GitHub GraphQL API...");
+
+ using var httpClient = new HttpClient();
+
+ using var requestContent = new StringContent(JsonSerializer.Serialize(new
+ {
+ query = CreateGraphQLQuery(todoOptions)
+ }));
+ requestContent.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Json, Encoding.UTF8.WebName);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.github.com/graphql");
+ request.Content = requestContent;
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", todoOptions.Token);
+ request.Headers.TryAddWithoutValidation("User-Agent", todoOptions.Username);
+
+ using var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ _logger.LogInformation("GitHub server returned status code: {}", response.StatusCode);
+ _logger.LogInformation("GitHub server returned body: {}", responseBody);
+
+ if (response.IsSuccessStatusCode)
+ {
+ using var responseJson = JsonSerializer.Deserialize<JsonDocument>(responseBody);
+ if (responseJson is null)
+ {
+ throw new Exception("Fail to deserialize response body.");
+ }
+
+ var nodes = responseJson.RootElement.GetProperty("data").GetProperty("user").GetProperty("projectV2").GetProperty("items").GetProperty("nodes").EnumerateArray();
+
+ var result = new List<TodosItem>();
+
+ foreach (var node in nodes)
+ {
+ var content = node.GetProperty("content");
+ var title = content.GetProperty("title").GetString();
+ if (title is null)
+ {
+ throw new Exception("Fail to get title.");
+ }
+
+ bool done = false;
+
+ var statusField = node.GetProperty("fieldValueByName");
+ if (statusField.ValueKind != JsonValueKind.Null) // if there is a "Status" field
+ {
+ var statusName = statusField.GetProperty("name").GetString();
+ if (statusName is null)
+ {
+ throw new Exception("Fail to get status.");
+ }
+
+ // if name is "Done", then it is closed, otherwise we check if the issue is closed
+ if (statusName.Equals("Done", StringComparison.OrdinalIgnoreCase))
+ {
+ done = true;
+ }
+ }
+
+ JsonElement closedElement;
+ // if item has a "closed" field, then it is a pull request or an issue, and we check if it is closed
+ if (content.TryGetProperty("closed", out closedElement) && closedElement.GetBoolean())
+ {
+ done = true;
+ }
+
+ // If item "Status" field is "Done' or item is a pull request or issue and it is closed, then it is done.
+ // Otherwise it is not closed. Like:
+ // 1. it is a draft issue with no "Status" field or "Status" field is not "Done"
+ // 2. it is a pull request or issue with no "Status" field or "Status" field is not "Done" and it is not closed
+
+ result.Add(new TodosItem
+ {
+ Title = title,
+ Status = done ? "Done" : "Todo",
+ Closed = done,
+ Color = done ? "green" : "blue"
+ });
+ }
+
+ return result;
+ }
+ else
+ {
+ const string message = "Fail to get todos from GitHub.";
+ _logger.LogError(message);
+ throw new Exception(message);
+ }
+ }
+}
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosServiceCollectionExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosServiceCollectionExtensions.cs
new file mode 100644
index 0000000..a49d55d
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosServiceCollectionExtensions.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CrupestApi.Todos;
+
+public static class TodosServiceCollectionExtensions
+{
+ public static IServiceCollection AddTodos(this IServiceCollection services)
+ {
+ services.AddOptions<TodosConfiguration>().BindConfiguration("CrupestApi:Todos");
+ services.PostConfigure<TodosConfiguration>(config =>
+ {
+ if (config.Count == 0)
+ {
+ config.Count = 20;
+ }
+ });
+ services.TryAddScoped<TodosService>();
+ return services;
+ }
+}
+
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosWebApplicationExtensions.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosWebApplicationExtensions.cs
new file mode 100644
index 0000000..0ff05a0
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.Todos/TodosWebApplicationExtensions.cs
@@ -0,0 +1,32 @@
+using CrupestApi.Commons;
+
+namespace CrupestApi.Todos;
+
+public static class TodosWebApplicationExtensions
+{
+ public static WebApplication MapTodos(this WebApplication app, string path)
+ {
+ if (app is null)
+ {
+ throw new ArgumentNullException(nameof(app));
+ }
+
+ app.MapGet(path, async (context) =>
+ {
+ var todosService = context.RequestServices.GetRequiredService<TodosService>();
+
+ try
+ {
+ var todos = await todosService.GetTodosAsync();
+ await context.Response.WriteJsonAsync(todos);
+
+ }
+ catch (Exception e)
+ {
+ await context.Response.WriteMessageAsync(e.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+ });
+
+ return app;
+ }
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi.sln b/dropped/docker/crupest-api/CrupestApi/CrupestApi.sln
new file mode 100644
index 0000000..ebfd960
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi.sln
@@ -0,0 +1,46 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrupestApi", "CrupestApi\CrupestApi.csproj", "{E30916BB-08F9-45F0-BC1A-69B66AE79913}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrupestApi.Todos", "CrupestApi.Todos\CrupestApi.Todos.csproj", "{BF9F5F71-AE65-4896-8E6F-FE0D4AD0E7D1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrupestApi.Secrets", "CrupestApi.Secrets\CrupestApi.Secrets.csproj", "{9A7CC9F9-70CB-408A-ADFC-5119C0BDB236}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrupestApi.Commons", "CrupestApi.Commons\CrupestApi.Commons.csproj", "{38083CCA-E56C-4D24-BAB6-EEC30E0F478F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrupestApi.Commons.Tests", "CrupestApi.Commons.Tests\CrupestApi.Commons.Tests.csproj", "{0D0304BF-6A18-444C-BAF4-6ABFF98A0F77}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E30916BB-08F9-45F0-BC1A-69B66AE79913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E30916BB-08F9-45F0-BC1A-69B66AE79913}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E30916BB-08F9-45F0-BC1A-69B66AE79913}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E30916BB-08F9-45F0-BC1A-69B66AE79913}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BF9F5F71-AE65-4896-8E6F-FE0D4AD0E7D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BF9F5F71-AE65-4896-8E6F-FE0D4AD0E7D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BF9F5F71-AE65-4896-8E6F-FE0D4AD0E7D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BF9F5F71-AE65-4896-8E6F-FE0D4AD0E7D1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9A7CC9F9-70CB-408A-ADFC-5119C0BDB236}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A7CC9F9-70CB-408A-ADFC-5119C0BDB236}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A7CC9F9-70CB-408A-ADFC-5119C0BDB236}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A7CC9F9-70CB-408A-ADFC-5119C0BDB236}.Release|Any CPU.Build.0 = Release|Any CPU
+ {38083CCA-E56C-4D24-BAB6-EEC30E0F478F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {38083CCA-E56C-4D24-BAB6-EEC30E0F478F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {38083CCA-E56C-4D24-BAB6-EEC30E0F478F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {38083CCA-E56C-4D24-BAB6-EEC30E0F478F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0D0304BF-6A18-444C-BAF4-6ABFF98A0F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0D0304BF-6A18-444C-BAF4-6ABFF98A0F77}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0D0304BF-6A18-444C-BAF4-6ABFF98A0F77}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0D0304BF-6A18-444C-BAF4-6ABFF98A0F77}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi/CrupestApi.csproj b/dropped/docker/crupest-api/CrupestApi/CrupestApi/CrupestApi.csproj
new file mode 100644
index 0000000..5954f00
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi/CrupestApi.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <ItemGroup>
+ <ProjectReference Include="..\CrupestApi.Todos\CrupestApi.Todos.csproj" />
+ <ProjectReference Include="..\CrupestApi.Files\CrupestApi.Files.csproj" />
+ <ProjectReference Include="..\CrupestApi.Commons\CrupestApi.Commons.csproj" />
+ <ProjectReference Include="..\CrupestApi.Secrets\CrupestApi.Secrets.csproj" />
+ </ItemGroup>
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <SelfContained>false</SelfContained>
+ </PropertyGroup>
+
+</Project> \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi/Program.cs b/dropped/docker/crupest-api/CrupestApi/CrupestApi/Program.cs
new file mode 100644
index 0000000..46648d9
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi/Program.cs
@@ -0,0 +1,24 @@
+using CrupestApi.Commons;
+using CrupestApi.Commons.Crud;
+using CrupestApi.Secrets;
+using CrupestApi.Todos;
+
+var builder = WebApplication.CreateBuilder(args);
+
+string configFilePath = Environment.GetEnvironmentVariable("CRUPEST_API_CONFIG_FILE") ?? "/crupest-api-config.json";
+builder.Configuration.AddJsonFile(configFilePath, optional: false, reloadOnChange: true);
+
+builder.Services.AddJsonOptions();
+builder.Services.AddCrupestApiConfig();
+
+builder.Services.AddTodos();
+builder.Services.AddSecrets();
+
+var app = builder.Build();
+
+app.UseCrudCore();
+app.MapTodos("/api/todos");
+// TODO: It's not safe now!
+// app.MapSecrets("/api/secrets");
+
+app.Run();
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi/Properties/launchSettings.json b/dropped/docker/crupest-api/CrupestApi/CrupestApi/Properties/launchSettings.json
new file mode 100644
index 0000000..a4a5cbf
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "dev": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:5188",
+ "workingDirectory": ".",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "CRUPEST_API_CONFIG_FILE": "dev-config.json"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/CrupestApi/CrupestApi/appsettings.json b/dropped/docker/crupest-api/CrupestApi/CrupestApi/appsettings.json
new file mode 100644
index 0000000..53753bd
--- /dev/null
+++ b/dropped/docker/crupest-api/CrupestApi/CrupestApi/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+} \ No newline at end of file
diff --git a/dropped/docker/crupest-api/Dockerfile b/dropped/docker/crupest-api/Dockerfile
new file mode 100644
index 0000000..feb7522
--- /dev/null
+++ b/dropped/docker/crupest-api/Dockerfile
@@ -0,0 +1,13 @@
+FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
+COPY CrupestApi /CrupestApi
+WORKDIR /CrupestApi
+RUN dotnet publish CrupestApi/CrupestApi.csproj --configuration Release --output ./publish -r linux-x64
+
+FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine
+ENV ASPNETCORE_URLS=http://0.0.0.0:5000
+ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
+COPY --from=build /CrupestApi/publish /CrupestApi
+WORKDIR /CrupestApi
+VOLUME [ "/crupest-api-config.json" ]
+EXPOSE 5000
+ENTRYPOINT ["dotnet", "CrupestApi.dll"]