Backend (.NET 10): - EventFlow CQRS/Event Sourcing with PostgreSQL - GraphQL.NET API with mutations and queries - Custom ReadModelSqlGenerator for snake_case PostgreSQL columns - Hangfire for background job processing - Integration tests with isolated test databases Frontend (React/Vite): - Initial project structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
221 lines
8.4 KiB
C#
221 lines
8.4 KiB
C#
// The MIT License (MIT)
|
|
//
|
|
// Copyright (c) 2015-2025 Rasmus Mikkelsen
|
|
// https://github.com/eventflow/EventFlow
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
// this software and associated documentation files (the "Software"), to deal in
|
|
// the Software without restriction, including without limitation the rights to
|
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
// the Software, and to permit persons to whom the Software is furnished to do so,
|
|
// subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in all
|
|
// copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.ComponentModel.DataAnnotations.Schema;
|
|
using System.Reflection;
|
|
using EventFlow.Extensions;
|
|
using EventFlow.ReadStores;
|
|
using EventFlow.Sql.ReadModels;
|
|
using EventFlow.Sql.ReadModels.Attributes;
|
|
|
|
namespace Books.Api.EventFlow.Customs;
|
|
|
|
/// <summary>
|
|
/// Custom SQL generator for PostgreSQL that uses snake_case column names
|
|
/// while keeping PascalCase property names for Dapper parameter binding.
|
|
/// </summary>
|
|
public class ReadModelSqlGenerator : IReadModelSqlGenerator
|
|
{
|
|
private static readonly ConcurrentDictionary<Type, string> TableNames = new();
|
|
private static readonly ConcurrentDictionary<Type, IReadOnlyCollection<PropertyInfo>> PropertyInfos = new();
|
|
private static readonly ConcurrentDictionary<Type, string> IdentityColumns = new();
|
|
private static readonly ConcurrentDictionary<Type, string> IdentityProperties = new();
|
|
private static readonly ConcurrentDictionary<Type, string> VersionColumns = new();
|
|
|
|
private readonly ConcurrentDictionary<Type, string?> _insertSqls = new();
|
|
private readonly ConcurrentDictionary<Type, string> _purgeSqls = new();
|
|
private readonly ConcurrentDictionary<Type, string> _deleteSqls = new();
|
|
private readonly ConcurrentDictionary<Type, string> _selectSqls = new();
|
|
private readonly ConcurrentDictionary<Type, string> _updateSqls = new();
|
|
|
|
public string? CreateInsertSql<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
var readModelType = typeof(TReadModel);
|
|
if (_insertSqls.TryGetValue(readModelType, out var sql))
|
|
{
|
|
return sql;
|
|
}
|
|
|
|
var properties = GetPropertyInfos(readModelType);
|
|
|
|
// Column names in snake_case
|
|
var columnList = string.Join(", ", properties.Select(p => p.Name.ToSnakeCase()));
|
|
|
|
// Parameter names use property names (PascalCase) for Dapper binding
|
|
var parameterList = string.Join(", ", properties.Select(p => $"@{p.Name}"));
|
|
|
|
sql = $"INSERT INTO {GetTableName<TReadModel>()} ({columnList}) VALUES ({parameterList})";
|
|
|
|
_insertSqls[readModelType] = sql;
|
|
|
|
return sql;
|
|
}
|
|
|
|
public string CreateSelectSql<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
var readModelType = typeof(TReadModel);
|
|
if (_selectSqls.TryGetValue(readModelType, out var sql))
|
|
{
|
|
return sql;
|
|
}
|
|
|
|
var tableName = GetTableName<TReadModel>();
|
|
var identityColumn = GetIdentityColumn<TReadModel>();
|
|
var properties = GetPropertyInfos(readModelType);
|
|
|
|
// Use explicit column aliases to map snake_case columns to PascalCase properties
|
|
var columnList = string.Join(", ", properties.Select(p => $"{p.Name.ToSnakeCase()} AS \"{p.Name}\""));
|
|
|
|
sql = $"SELECT {columnList} FROM {tableName} WHERE {identityColumn} = @EventFlowReadModelId";
|
|
|
|
_selectSqls[readModelType] = sql;
|
|
|
|
return sql;
|
|
}
|
|
|
|
public string CreateDeleteSql<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
var readModelType = typeof(TReadModel);
|
|
if (_deleteSqls.TryGetValue(readModelType, out var sql))
|
|
{
|
|
return sql;
|
|
}
|
|
|
|
sql = $"DELETE FROM {GetTableName<TReadModel>()} WHERE {GetIdentityColumn<TReadModel>()} = @EventFlowReadModelId";
|
|
_deleteSqls[readModelType] = sql;
|
|
|
|
return sql;
|
|
}
|
|
|
|
public string CreateUpdateSql<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
var readModelType = typeof(TReadModel);
|
|
if (_updateSqls.TryGetValue(readModelType, out var sql))
|
|
{
|
|
return sql;
|
|
}
|
|
|
|
var identityColumn = GetIdentityColumn<TReadModel>();
|
|
var identityProperty = GetIdentityProperty<TReadModel>();
|
|
var versionColumn = GetVersionColumn<TReadModel>();
|
|
|
|
var versionCheck = string.IsNullOrEmpty(versionColumn)
|
|
? string.Empty
|
|
: $"AND {versionColumn} = @_PREVIOUS_VERSION";
|
|
|
|
var properties = GetPropertyInfos(readModelType);
|
|
var updateColumns = properties
|
|
.Where(p => p.Name.ToSnakeCase() != identityColumn)
|
|
.Select(p => $"{p.Name.ToSnakeCase()} = @{p.Name}");
|
|
|
|
var tableName = GetTableName<TReadModel>();
|
|
|
|
sql = $"UPDATE {tableName} SET {string.Join(", ", updateColumns)} WHERE {identityColumn} = @{identityProperty} {versionCheck}";
|
|
|
|
_updateSqls[readModelType] = sql;
|
|
|
|
return sql;
|
|
}
|
|
|
|
public string CreatePurgeSql<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
return _purgeSqls.GetOrCreate(typeof(TReadModel), t => $"DELETE FROM {GetTableName(t)}");
|
|
}
|
|
|
|
public string GetTableName<TReadModel>() where TReadModel : IReadModel
|
|
{
|
|
return GetTableName(typeof(TReadModel));
|
|
}
|
|
|
|
private static string GetTableName(Type readModelType)
|
|
{
|
|
return TableNames.GetOrAdd(
|
|
readModelType,
|
|
t =>
|
|
{
|
|
var tableAttribute = t.GetTypeInfo().GetCustomAttribute<TableAttribute>(false);
|
|
var table = string.IsNullOrEmpty(tableAttribute?.Name)
|
|
? $"ReadModel-{t.Name.Replace("ReadModel", string.Empty)}"
|
|
: tableAttribute.Name;
|
|
return string.IsNullOrEmpty(tableAttribute?.Schema)
|
|
? $"\"{table}\""
|
|
: $"\"{tableAttribute?.Schema}\".\"{table}\"";
|
|
});
|
|
}
|
|
|
|
private static string GetIdentityColumn<TReadModel>()
|
|
{
|
|
return IdentityColumns.GetOrAdd(
|
|
typeof(TReadModel),
|
|
t =>
|
|
{
|
|
var propertyInfo = GetPropertyInfos(t)
|
|
.SingleOrDefault(
|
|
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
|
|
return (propertyInfo?.Name ?? "AggregateId").ToSnakeCase();
|
|
});
|
|
}
|
|
|
|
private static string GetIdentityProperty<TReadModel>()
|
|
{
|
|
return IdentityProperties.GetOrAdd(
|
|
typeof(TReadModel),
|
|
t =>
|
|
{
|
|
var propertyInfo = GetPropertyInfos(t)
|
|
.SingleOrDefault(
|
|
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
|
|
return propertyInfo?.Name ?? "AggregateId";
|
|
});
|
|
}
|
|
|
|
private static string GetVersionColumn<TReadModel>()
|
|
{
|
|
return VersionColumns.GetOrAdd(
|
|
typeof(TReadModel),
|
|
t =>
|
|
{
|
|
var propertyInfo = GetPropertyInfos(t)
|
|
.SingleOrDefault(
|
|
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelVersionColumnAttribute));
|
|
if (propertyInfo != null)
|
|
{
|
|
return propertyInfo.Name.ToSnakeCase();
|
|
}
|
|
|
|
return GetPropertyInfos(t).Any(n => n.Name == "LastAggregateSequenceNumber")
|
|
? "last_aggregate_sequence_number"
|
|
: string.Empty;
|
|
});
|
|
}
|
|
|
|
private static IReadOnlyCollection<PropertyInfo> GetPropertyInfos(Type readModelType)
|
|
{
|
|
return PropertyInfos.GetOrAdd(
|
|
readModelType,
|
|
t => t.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
|
.Where(p => !p.GetCustomAttributes().Any(a => a is SqlReadModelIgnoreColumnAttribute))
|
|
.OrderBy(p => p.Name)
|
|
.ToList());
|
|
}
|
|
}
|