Added database table data into configuration. Store saves is auto-handled. Added Action & Redemption stores.

This commit is contained in:
Tom
2024-12-31 18:31:21 +00:00
parent 538bf07454
commit 3429c8f8dc
29 changed files with 657 additions and 374 deletions

View File

@ -0,0 +1,11 @@
namespace HermesSocketServer.Store.Internal
{
public class DatabaseTable
{
public required string TableName { get; set; }
public required string[] KeyColumns { get; set; }
public required string[] DataColumns { get; set; }
public required IDictionary<string, string> PropertyMapping { get; set; }
public IDictionary<string, string>? TypeMapping { get; set; }
}
}

View File

@ -0,0 +1,318 @@
using System.Reflection;
using System.Text;
using System.Text.Json;
using HermesSocketLibrary.db;
using NpgsqlTypes;
namespace HermesSocketServer.Store.Internal
{
public class GroupSaveSqlGenerator<T>
{
private readonly IDictionary<string, PropertyInfo?> _columnPropertyRelations;
private readonly IDictionary<string, NpgsqlDbType> _columnTypes;
private readonly Serilog.ILogger _logger;
public GroupSaveSqlGenerator(IDictionary<string, string> columnsToProperties, Serilog.ILogger logger)
: this(columnsToProperties, new Dictionary<string, NpgsqlDbType>(), logger)
{
}
public GroupSaveSqlGenerator(IDictionary<string, string> columnsToProperties, IDictionary<string, NpgsqlDbType> columnTypes, Serilog.ILogger logger)
{
_columnPropertyRelations = columnsToProperties.ToDictionary(p => p.Key, p => typeof(T).GetProperty(p.Value));
_columnTypes = columnTypes;
_logger = logger;
var nullProperties = _columnPropertyRelations.Where(p => p.Value == null)
.Select(p => columnsToProperties[p.Key]);
if (nullProperties.Any())
throw new ArgumentException("Some properties do not exist on the values given: " + string.Join(", ", nullProperties));
}
public async Task DoPreparedStatement<V>(Database database, string sql, IEnumerable<V> values, string[] columns)
{
try
{
await database.Execute(sql, (c) =>
{
var valueCounter = 0;
foreach (var value in values)
{
foreach (var column in columns)
{
var propValue = _columnPropertyRelations[column]!.GetValue(value);
if (_columnTypes.Any() && _columnTypes.TryGetValue(column, out var type))
{
if (type == NpgsqlDbType.Jsonb)
propValue = JsonSerializer.Serialize(propValue);
c.Parameters.AddWithValue(column.ToLower() + valueCounter, type, propValue ?? DBNull.Value);
}
else
c.Parameters.AddWithValue(column.ToLower() + valueCounter, propValue ?? DBNull.Value);
}
valueCounter++;
}
});
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to execute a prepared statement: " + sql);
}
}
public async Task DoPreparedStatementRaw<V>(Database database, string sql, IEnumerable<V> values, string[] columns)
{
try
{
await database.Execute(sql, (c) =>
{
var valueCounter = 0;
foreach (var value in values)
{
foreach (var column in columns)
{
object? propValue = value;
c.Parameters.AddWithValue(column.ToLower() + valueCounter, propValue ?? DBNull.Value);
}
valueCounter++;
}
});
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to execute a prepared statement: " + sql);
}
}
public string GenerateInsertSql(string table, IEnumerable<T> values, IEnumerable<string> columns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (values == null)
throw new ArgumentNullException(nameof(values));
if (!values.Any())
throw new ArgumentException("Empty list given.", nameof(values));
if (columns == null)
throw new ArgumentNullException(nameof(columns));
if (!columns.Any())
throw new ArgumentException("Empty list given.", nameof(columns));
var ctp = columns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
sb.Append($"INSERT INTO \"{table}\" (\"{string.Join("\", \"", columns)}\") VALUES ");
foreach (var value in values)
{
sb.Append("(");
foreach (var column in columns)
{
var propValue = _columnPropertyRelations[column]!.GetValue(value);
var propType = _columnPropertyRelations[column]!.PropertyType;
WriteValue(sb, propValue ?? DBNull.Value, propType);
sb.Append(",");
}
sb.Remove(sb.Length - 1, 1)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append(';');
return sb.ToString();
}
public string GeneratePreparedInsertSql(string table, int rows, IEnumerable<string> columns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (columns == null)
throw new ArgumentNullException(nameof(columns));
if (!columns.Any())
throw new ArgumentException("Empty list given.", nameof(columns));
var ctp = columns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
var columnsLower = columns.Select(c => c.ToLower());
sb.Append($"INSERT INTO \"{table}\" (\"{string.Join("\", \"", columns)}\") VALUES ");
for (var row = 0; row < rows; row++)
{
sb.Append("(");
foreach (var column in columnsLower)
{
sb.Append('@')
.Append(column)
.Append(row)
.Append(", ");
}
sb.Remove(sb.Length - 2, 2)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append(';');
return sb.ToString();
}
public string GenerateUpdateSql(string table, IEnumerable<T> values, IEnumerable<string> keyColumns, IEnumerable<string> updateColumns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (values == null)
throw new ArgumentNullException(nameof(values));
if (!values.Any())
throw new ArgumentException("Empty list given.", nameof(values));
if (keyColumns == null)
throw new ArgumentNullException(nameof(keyColumns));
if (!keyColumns.Any())
throw new ArgumentException("Empty list given.", nameof(keyColumns));
if (updateColumns == null)
throw new ArgumentNullException(nameof(updateColumns));
if (!updateColumns.Any())
throw new ArgumentException("Empty list given.", nameof(updateColumns));
var columns = keyColumns.Union(updateColumns);
var ctp = columns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
sb.Append($"UPDATE \"{table}\" as t SET {string.Join(", ", updateColumns.Select(c => "\"" + c + "\" = c.\"" + c + "\""))} FROM (VALUES ");
foreach (var value in values)
{
sb.Append("(");
foreach (var column in columns)
{
var propValue = _columnPropertyRelations[column]!.GetValue(value);
var propType = _columnPropertyRelations[column]!.PropertyType;
WriteValue(sb, propValue, propType);
sb.Append(",");
}
sb.Remove(sb.Length - 1, 1)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append($") AS c(\"{string.Join("\", \"", columns)}\") WHERE ")
.Append(string.Join(" AND ", keyColumns.Select(c => "t.\"" + c + "\" = c.\"" + c + "\"")))
.Append(";");
return sb.ToString();
}
public string GeneratePreparedUpdateSql(string table, int rows, IEnumerable<string> keyColumns, IEnumerable<string> updateColumns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (keyColumns == null)
throw new ArgumentNullException(nameof(keyColumns));
if (!keyColumns.Any())
throw new ArgumentException("Empty list given.", nameof(keyColumns));
if (updateColumns == null)
throw new ArgumentNullException(nameof(updateColumns));
if (!updateColumns.Any())
throw new ArgumentException("Empty list given.", nameof(updateColumns));
var columns = keyColumns.Union(updateColumns);
var ctp = columns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
sb.Append($"UPDATE \"{table}\" as t SET {string.Join(", ", updateColumns.Select(c => "\"" + c + "\" = c.\"" + c + "\""))} FROM (VALUES ");
for (var row = 0; row < rows; row++)
{
sb.Append("(");
foreach (var column in columns)
{
sb.Append('@')
.Append(column)
.Append(row)
.Append(", ");
}
sb.Remove(sb.Length - 2, 2)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append($") AS c(\"{string.Join("\", \"", columns)}\") WHERE ")
.Append(string.Join(" AND ", keyColumns.Select(c => "t.\"" + c + "\" = c.\"" + c + "\"")))
.Append(";");
return sb.ToString();
}
public string GenerateDeleteSql(string table, IEnumerable<string> keys, IEnumerable<string> keyColumns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (keys == null)
throw new ArgumentNullException(nameof(keys));
if (!keys.Any())
throw new ArgumentException("Empty list given.", nameof(keys));
if (keyColumns == null)
throw new ArgumentNullException(nameof(keyColumns));
if (!keyColumns.Any())
throw new ArgumentException("Empty list given.", nameof(keyColumns));
var ctp = keyColumns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
sb.Append($"DELETE FROM \"{table}\" WHERE (\"{string.Join("\", \"", keyColumns)}\") IN (");
foreach (var k in keys)
{
sb.Append("(");
foreach (var column in keyColumns)
{
var propType = _columnPropertyRelations[column]!.PropertyType;
WriteValue(sb, k, propType);
sb.Append(",");
}
sb.Remove(sb.Length - 1, 1)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append(");");
return sb.ToString();
}
public string GeneratePreparedDeleteSql(string table, int rows, IEnumerable<string> keyColumns)
{
if (string.IsNullOrWhiteSpace(table))
throw new ArgumentException("Value is either null or whitespace-filled.", nameof(table));
if (keyColumns == null)
throw new ArgumentNullException(nameof(keyColumns));
if (!keyColumns.Any())
throw new ArgumentException("Empty list given.", nameof(keyColumns));
var ctp = keyColumns.ToDictionary(c => c, c => _columnPropertyRelations[c]);
var sb = new StringBuilder();
sb.Append($"DELETE FROM \"{table}\" WHERE (\"{string.Join("\", \"", keyColumns)}\") IN (");
for (var row = 0; row < rows; row++)
{
sb.Append("(");
foreach (var column in keyColumns)
{
sb.Append('@')
.Append(column)
.Append(row)
.Append(", ");
}
sb.Remove(sb.Length - 2, 2)
.Append("),");
}
sb.Remove(sb.Length - 1, 1)
.Append(");");
return sb.ToString();
}
private void WriteValue(StringBuilder sb, object? value, Type type)
{
if (type == typeof(string))
sb.Append("'")
.Append(value)
.Append("'");
else if (type == typeof(Guid))
sb.Append("uuid('")
.Append(value?.ToString())
.Append("')");
else if (type == typeof(TimeSpan))
{
if (value == null)
sb.Append("0");
else
sb.Append(((TimeSpan)value).TotalMilliseconds);
}
else
sb.Append(value);
}
}
}

View File

@ -0,0 +1,139 @@
using System.Collections.Immutable;
namespace HermesSocketServer.Store.Internal
{
public abstract class GroupSaveStore<K, V> : IStore<K, V> where K : class where V : class
{
private readonly Serilog.ILogger _logger;
protected readonly IDictionary<K, V> _store;
protected readonly IList<K> _added;
protected readonly IList<K> _modified;
protected readonly IList<K> _deleted;
protected readonly object _lock;
public GroupSaveStore(Serilog.ILogger logger)
{
_logger = logger;
_store = new Dictionary<K, V>();
_added = new List<K>();
_modified = new List<K>();
_deleted = new List<K>();
_lock = new object();
}
public abstract Task Load();
protected abstract void OnInitialAdd(K key, V value);
protected abstract void OnInitialModify(K key, V value);
protected abstract void OnInitialRemove(K key);
public abstract Task Save();
public V? Get(K key)
{
lock (_lock)
{
if (_store.TryGetValue(key, out var value))
return value;
}
return null;
}
public IDictionary<K, V> Get()
{
lock (_lock)
{
return _store.ToImmutableDictionary();
}
}
public bool Modify(K? key, Action<V> action)
{
if (key == null)
return false;
lock (_lock)
{
if (_store.TryGetValue(key, out V? value))
{
if (value == null)
return false;
OnInitialModify(key, value);
action(value);
if (!_added.Contains(key) && !_modified.Contains(key))
{
_modified.Add(key);
_logger.Information($"added key to _modified {key}");
}
return true;
}
}
return false;
}
public bool Remove(K? key)
{
if (key == null)
return false;
lock (_lock)
{
OnInitialRemove(key);
if (_store.Remove(key))
{
_logger.Information($"removed key from _deleted {key}");
if (!_added.Remove(key))
{
_modified.Remove(key);
_logger.Information($"removed key from _added & _modified {key}");
if (!_deleted.Contains(key))
{
_deleted.Add(key);
_logger.Information($"added key to _deleted {key}");
}
}
return true;
}
}
return false;
}
public bool Set(K? key, V? value)
{
if (key == null || value == null)
return false;
lock (_lock)
{
if (_store.TryGetValue(key, out V? fetched))
{
if (fetched != value)
{
OnInitialModify(key, value);
_store[key] = value;
if (!_added.Contains(key) && !_modified.Contains(key))
{
_modified.Add(key);
_logger.Information($"added key to _modified {key}");
}
return true;
}
}
else
{
OnInitialAdd(key, value);
_store.Add(key, value);
if (!_deleted.Remove(key) && !_added.Contains(key))
{
_added.Add(key);
_logger.Information($"added key to _added {key}");
}
return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,61 @@
using System.Collections.Immutable;
using HermesSocketLibrary.db;
namespace HermesSocketServer.Store.Internal
{
public abstract class AutoSavedStore<K, V> : GroupSaveStore<K, V> where K : class where V : class
{
private readonly GroupSaveSqlGenerator<V> _generator;
private readonly DatabaseTable _table;
private readonly Database _database;
private readonly Serilog.ILogger _logger;
public AutoSavedStore(DatabaseTable table, Database database, Serilog.ILogger logger)
: base(logger)
{
_generator = new GroupSaveSqlGenerator<V>(table.PropertyMapping, logger);
_table = table;
_database = database;
_logger = logger;
}
public override async Task Save()
{
var allColumns = _table.KeyColumns.Union(_table.DataColumns).ToArray();
await GenerateQuery(_added,
(size) => _generator.GeneratePreparedInsertSql(_table.TableName, size, allColumns),
async (query, _, values) => await _generator.DoPreparedStatement(_database, query, values, allColumns));
await GenerateQuery(_modified,
(size) => _generator.GeneratePreparedUpdateSql(_table.TableName, size, _table.KeyColumns, _table.DataColumns),
async (query, _, values) => await _generator.DoPreparedStatement(_database, query, values, allColumns));
await GenerateQuery(_deleted,
(size) => _generator.GeneratePreparedDeleteSql(_table.TableName, size, _table.KeyColumns),
async (query, keys, _) => await _generator.DoPreparedStatementRaw(_database, query, keys, _table.KeyColumns));
}
private async Task GenerateQuery(IList<K> keys, Func<int, string> generate, Func<string, IEnumerable<K>, IEnumerable<V>, Task> execute)
{
ImmutableList<K>? list = null;
lock (_lock)
{
if (!keys.Any())
return;
list = keys.ToImmutableList();
keys.Clear();
}
var query = generate(list.Count);
_logger.Debug($"{_table.TableName} - Adding {list.Count} rows to database: {query}");
var values = list.Select(id => _store[id]).Where(v => v != null);
await execute(query, list, values);
}
}
}