books/backend/Books.Api/EventFlow/Customs/ReadModelSqlGenerator.cs
Nicolaj Hartmann 66f6fa138d Initial commit: Books accounting system with EventFlow CQRS
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>
2026-01-18 02:52:30 +01:00

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());
}
}