diff options
8 files changed, 155 insertions, 189 deletions
diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs new file mode 100644 index 0000000..0c42c67 --- /dev/null +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudServiceTest.cs @@ -0,0 +1,32 @@ +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, NullLoggerFactory.Instance); +    } + +    [Fact] +    public void CrudTest() +    { +        _crudService.Create(new TestEntity() +        { +            Name = "crupest", +            Age = 18, +        }); +    } + + +} diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudTestBase.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudTestBase.cs deleted file mode 100644 index 98c0dfd..0000000 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/CrudTestBase.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Net; -using CrupestApi.Commons.Secrets; -using Microsoft.AspNetCore.TestHost; - -namespace CrupestApi.Commons.Crud.Tests; - -public abstract class CrudTestBase<TEntity> : IAsyncDisposable where TEntity : class -{ -    protected readonly WebApplication _app; - -    protected readonly string _path; -    protected readonly string? _authKey; - -    protected readonly HttpClient _client; - -    public CrudTestBase(string path, string? authKey = null) -    { -        _path = path; -        _authKey = authKey; - -        var builder = WebApplication.CreateBuilder(); -        builder.WebHost.UseTestServer(); -        builder.Services.AddCrud<TEntity>(); -        ConfigureApplication(builder); -        _app = builder.Build(); - -        if (authKey is not null) -        { -            using (var scope = _app.Services.CreateScope()) -            { -                var secretService = scope.ServiceProvider.GetRequiredService<ISecretService>(); -                secretService.CreateTestSecret(authKey, "test-secret"); -            } -        } - -        _client = CreateHttpClient(); -    } - -    protected abstract void ConfigureApplication(WebApplicationBuilder builder); - -    public virtual async ValueTask DisposeAsync() -    { -        await _app.DisposeAsync(); -    } - -    public TestServer GetTestServer() -    { -        return _app.GetTestServer(); -    } - -    public HttpClient CreateHttpClient() -    { -        return GetTestServer().CreateClient(); -    } - -    public async Task TestAuth() -    { -        if (_authKey is null) -        { -            return; -        } - -        { -            using var response = await _client.GetAsync(_path); -            Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); -        } - -        { -            var entity = Activator.CreateInstance<TEntity>(); -            using var response = await _client.PostAsJsonAsync(_path, entity); -            Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); -        } -    } -} diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs index ca84d5a..7cc19ed 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TestEntity.cs @@ -2,7 +2,7 @@ namespace CrupestApi.Commons.Crud.Tests;  public class TestEntity  { -    [Column(NotNull = true)] +    [Column(ActAsKey = true, NotNull = true)]      public string Name { get; set; } = default!;      [Column(NotNull = true)] @@ -11,5 +11,13 @@ public class TestEntity      [Column]      public float? Height { get; set; } +    [Column(Generated = 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/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs index 1a2a055..33ff2ed 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs @@ -1,23 +1,29 @@  using System.Data; -using System.Text.Json;  using Dapper;  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; -    protected readonly EntityJsonHelper<TEntity> _jsonHelper; +    private readonly bool _shouldDisposeConnection;      private readonly ILogger<CrudService<TEntity>> _logger; -    public CrudService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, EntityJsonHelper<TEntity> jsonHelper, ILoggerFactory loggerFactory) +    public CrudService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, ILoggerFactory loggerFactory)      {          _connectionName = GetConnectionName();          _table = tableInfoFactory.Get(typeof(TEntity));          _dbConnection = dbConnectionFactory.Get(_connectionName); -        _jsonHelper = jsonHelper; +        _shouldDisposeConnection = dbConnectionFactory.ShouldDisposeConnection;          _logger = loggerFactory.CreateLogger<CrudService<TEntity>>();          CheckDatabase(_dbConnection); @@ -28,8 +34,6 @@ public class CrudService<TEntity> : IDisposable where TEntity : class          return typeof(TEntity).Name;      } -    public EntityJsonHelper<TEntity> JsonHelper => _jsonHelper; -      protected virtual void CheckDatabase(IDbConnection dbConnection)      {          if (!_table.CheckExistence(dbConnection)) @@ -47,7 +51,8 @@ public class CrudService<TEntity> : IDisposable where TEntity : class      public void Dispose()      { -        _dbConnection.Dispose(); +        if (_shouldDisposeConnection) +            _dbConnection.Dispose();      }      public List<TEntity> GetAll() @@ -80,17 +85,23 @@ public class CrudService<TEntity> : IDisposable where TEntity : class          return (string)key;      } -    public string Create(JsonElement jsonElement) +    public IUpdateClause ConvertEntityToUpdateClauses(TEntity entity, UpdateBehavior behavior)      { -        var insertClauses = _jsonHelper.ConvertJsonElementToInsertClauses(jsonElement); -        var key = _table.Insert(_dbConnection, insertClauses); -        return (string)key; +        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;      } -    public void UpdateByKey(object key, JsonElement jsonElement) +    public void UpdateByKey(object key, TEntity entity, UpdateBehavior behavior)      { -        var updateClauses = _jsonHelper.ConvertJsonElementToUpdateClause(jsonElement); -        _table.Update(_dbConnection, WhereClause.Create().Eq(_table.KeyColumn.ColumnName, key), updateClauses); +        _table.Update(_dbConnection, WhereClause.Create().Eq(_table.KeyColumn.ColumnName, key), +            ConvertEntityToUpdateClauses(entity, behavior));      }      public void DeleteByKey(object key) diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs index b7bc6f1..c91c969 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs @@ -8,14 +8,16 @@ public static class CrudWebApplicationExtensions          {              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 => crudService.JsonHelper.ConvertEntityToDictionary(e))); +            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)              { @@ -24,23 +26,25 @@ public static class CrudWebApplicationExtensions              }              var entity = crudService.GetByKey(key); -            await context.ResponseJsonAsync(crudService.JsonHelper.ConvertEntityToDictionary(entity)); +            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(jsonDocument.RootElement); -            await context.ResponseJsonAsync(crudService.JsonHelper.ConvertEntityToDictionary(crudService.GetByKey(key))); +            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 crudService = context.RequestServices.GetRequiredService<CrudService<TEntity>>();              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."); @@ -48,9 +52,9 @@ public static class CrudWebApplicationExtensions              }              var jsonDocument = await context.Request.ReadJsonAsync(); -            crudService.UpdateByKey(key, jsonDocument.RootElement); - -            await context.ResponseJsonAsync(crudService.JsonHelper.ConvertEntityToDictionary(crudService.GetByKey(key))); +            var entity = entityJsonHelper.ConvertJsonToEntityForUpdate(jsonDocument.RootElement, out var updateBehavior); +            crudService.UpdateByKey(key, entity, updateBehavior); +            await context.ResponseJsonAsync(entityJsonHelper.ConvertEntityToDictionary(crudService.GetByKey(key)));          });          app.MapDelete(path + "/{key}", async (context) => diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs index e8f8abf..85b818b 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs @@ -7,6 +7,7 @@ namespace CrupestApi.Commons.Crud;  public interface IDbConnectionFactory  {      IDbConnection Get(string? name = null); +    bool ShouldDisposeConnection { get; }  }  public class SqliteConnectionFactory : IDbConnectionFactory @@ -28,4 +29,44 @@ public class SqliteConnectionFactory : IDbConnectionFactory          return new SqliteConnection(connectionString);      } + +    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); +            return connection; +        } +    } + +    public bool ShouldDisposeConnection => false; + + +    public void Dispose() +    { +        foreach (var connection in _connections.Values) +        { +            connection.Dispose(); +        } +    }  } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs index 1265fe9..4489307 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/EntityJsonHelper.cs @@ -17,7 +17,7 @@ public class EntityJsonHelper<TEntity> where TEntity : class          _jsonSerializerOptions = jsonSerializerOptions;      } -    public virtual Dictionary<string, object?> ConvertEntityToDictionary(TEntity entity, bool includeNonColumnProperties = false) +    public Dictionary<string, object?> ConvertEntityToDictionary(TEntity entity, bool includeNonColumnProperties = false)      {          var result = new Dictionary<string, object?>(); @@ -40,131 +40,75 @@ public class EntityJsonHelper<TEntity> where TEntity : class          return result;      } -    public virtual string ConvertEntityToJson(TEntity entity, bool includeNonColumnProperties = false) +    public string ConvertEntityToJson(TEntity entity, bool includeNonColumnProperties = false)      {          var dictionary = ConvertEntityToDictionary(entity, includeNonColumnProperties);          return JsonSerializer.Serialize(dictionary, _jsonSerializerOptions.CurrentValue);      } -    public virtual IInsertClause ConvertJsonElementToInsertClauses(JsonElement rootElement) +    public TEntity ConvertJsonToEntityForInsert(JsonElement jsonElement)      { -        var insertClause = InsertClause.Create(); - -        if (rootElement.ValueKind != JsonValueKind.Object) -        { -            throw new UserException("The root element must be an object."); -        } +        if (jsonElement.ValueKind is not JsonValueKind.Object) +            throw new ArgumentException("The jsonElement must be an object."); +        var result = Activator.CreateInstance<TEntity>();          foreach (var column in _table.PropertyColumns)          { -            object? value = null; -            if (rootElement.TryGetProperty(column.ColumnName, out var propertyElement)) -            { -                value = propertyElement.ValueKind switch -                { -                    JsonValueKind.Null or JsonValueKind.Undefined => null, -                    JsonValueKind.Number => propertyElement.GetDouble(), -                    JsonValueKind.True => true, -                    JsonValueKind.False => false, -                    JsonValueKind.String => propertyElement.GetString(), -                    _ => throw new Exception($"Bad json value of property {column.ColumnName}.") -                }; -            } - -            if (column.IsGenerated && value is not null) -            { -                throw new UserException($"The property {column.ColumnName} is generated. You cannot specify its value."); -            } - -            if (column.IsNotNull && !column.CanBeGenerated && value is null) +            if (jsonElement.TryGetProperty(column.ColumnName, out var value))              { -                throw new UserException($"The property {column.ColumnName} can't be null or generated. But you specify a null value."); +                var realValue = column.ColumnType.ConvertFromDatabase(value); +                column.PropertyInfo!.SetValue(result, realValue);              } - -            insertClause.Add(column.ColumnName, value);          } -        return insertClause; +        return result;      } -    public IInsertClause ConvertJsonToInsertClauses(string json) +    public TEntity ConvertJsonToEntityForInsert(string json)      { -        var document = JsonSerializer.Deserialize<JsonDocument>(json, _jsonSerializerOptions.CurrentValue)!; -        return ConvertJsonElementToInsertClauses(document.RootElement); +        var jsonElement = JsonSerializer.Deserialize<JsonElement>(json, _jsonSerializerOptions.CurrentValue); +        return ConvertJsonToEntityForInsert(jsonElement!);      } -    public IUpdateClause ConvertJsonElementToUpdateClause(JsonElement rootElement, bool saveNull) +    public TEntity ConvertJsonToEntityForUpdate(JsonElement jsonElement, out UpdateBehavior updateBehavior)      { -        var updateClause = UpdateClause.Create(); +        if (jsonElement.ValueKind is not JsonValueKind.Object) +            throw new UserException("The jsonElement must be an object."); -        if (rootElement.ValueKind != JsonValueKind.Object) -        { -            throw new UserException("The root element must be an object."); -        } +        updateBehavior = UpdateBehavior.None; -        foreach (var column in _table.PropertyColumns) +        if (jsonElement.TryGetProperty("$saveNull", out var saveNullValue))          { -            object? value = null; - -            if (rootElement.TryGetProperty(column.ColumnName, out var propertyElement)) +            if (saveNullValue.ValueKind is JsonValueKind.True)              { -                value = propertyElement.ValueKind switch -                { -                    JsonValueKind.Null or JsonValueKind.Undefined => null, -                    JsonValueKind.Number => propertyElement.GetDouble(), -                    JsonValueKind.True => true, -                    JsonValueKind.False => false, -                    JsonValueKind.String => propertyElement.GetString(), -                    _ => throw new Exception($"Bad json value of property {column.ColumnName}.") -                }; - -                if (column.IsNoUpdate && (value is not null || saveNull)) -                { -                    throw new UserException($"The property {column.ColumnName} is not updatable. You cannot specify its value."); -                } +                updateBehavior |= UpdateBehavior.SaveNull;              } +            else if (saveNullValue.ValueKind is JsonValueKind.False) +            { -            if (value is null && !saveNull) +            } +            else              { -                continue; +                throw new UserException("The $saveNull must be a boolean.");              } - -            updateClause.Add(column.ColumnName, value ?? DbNullValue.Instance);          } -        return updateClause; -    } - -    public IUpdateClause ConvertJsonElementToUpdateClause(JsonElement rootElement) -    { -        var updateClause = UpdateClause.Create(); - -        if (rootElement.ValueKind != JsonValueKind.Object) -        { -            throw new UserException("The root element must be an object."); -        } - -        bool saveNull = false; - -        if (rootElement.TryGetProperty("$saveNull", out var propertyElement)) +        var result = Activator.CreateInstance<TEntity>(); +        foreach (var column in _table.PropertyColumns)          { -            if (propertyElement.ValueKind is not JsonValueKind.True or JsonValueKind.False) +            if (jsonElement.TryGetProperty(column.ColumnName, out var value))              { -                throw new UserException("$saveNull can only be true or false."); -            } - -            if (propertyElement.ValueKind is JsonValueKind.True) -            { -                saveNull = true; +                var realValue = column.ColumnType.ConvertFromDatabase(value); +                column.PropertyInfo!.SetValue(result, realValue);              }          } -        return ConvertJsonElementToUpdateClause(rootElement, saveNull); +        return result;      } -    public IUpdateClause ConvertJsonToUpdateClause(string json) +    public TEntity ConvertJsonToEntityForUpdate(string json, out UpdateBehavior updateBehavior)      { -        var document = JsonSerializer.Deserialize<JsonDocument>(json, _jsonSerializerOptions.CurrentValue)!; -        return ConvertJsonElementToUpdateClause(document.RootElement); +        var jsonElement = JsonSerializer.Deserialize<JsonElement>(json, _jsonSerializerOptions.CurrentValue); +        return ConvertJsonToEntityForUpdate(jsonElement!, out updateBehavior);      }  } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs index 69acad3..c2242ff 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Secrets/SecretService.cs @@ -5,8 +5,8 @@ namespace CrupestApi.Commons.Secrets;  public class SecretService : CrudService<SecretInfo>, ISecretService  { -    public SecretService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, EntityJsonHelper<SecretInfo> jsonHelper, ILoggerFactory loggerFactory) -        : base(tableInfoFactory, dbConnectionFactory, jsonHelper, loggerFactory) +    public SecretService(ITableInfoFactory tableInfoFactory, IDbConnectionFactory dbConnectionFactory, ILoggerFactory loggerFactory) +        : base(tableInfoFactory, dbConnectionFactory, loggerFactory)      {      }  | 
