// 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; /// /// Custom SQL generator for PostgreSQL that uses snake_case column names /// while keeping PascalCase property names for Dapper parameter binding. /// public class ReadModelSqlGenerator : IReadModelSqlGenerator { private static readonly ConcurrentDictionary TableNames = new(); private static readonly ConcurrentDictionary> PropertyInfos = new(); private static readonly ConcurrentDictionary IdentityColumns = new(); private static readonly ConcurrentDictionary IdentityProperties = new(); private static readonly ConcurrentDictionary VersionColumns = new(); private readonly ConcurrentDictionary _insertSqls = new(); private readonly ConcurrentDictionary _purgeSqls = new(); private readonly ConcurrentDictionary _deleteSqls = new(); private readonly ConcurrentDictionary _selectSqls = new(); private readonly ConcurrentDictionary _updateSqls = new(); public string? CreateInsertSql() 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()} ({columnList}) VALUES ({parameterList})"; _insertSqls[readModelType] = sql; return sql; } public string CreateSelectSql() where TReadModel : IReadModel { var readModelType = typeof(TReadModel); if (_selectSqls.TryGetValue(readModelType, out var sql)) { return sql; } var tableName = GetTableName(); var identityColumn = GetIdentityColumn(); 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() where TReadModel : IReadModel { var readModelType = typeof(TReadModel); if (_deleteSqls.TryGetValue(readModelType, out var sql)) { return sql; } sql = $"DELETE FROM {GetTableName()} WHERE {GetIdentityColumn()} = @EventFlowReadModelId"; _deleteSqls[readModelType] = sql; return sql; } public string CreateUpdateSql() where TReadModel : IReadModel { var readModelType = typeof(TReadModel); if (_updateSqls.TryGetValue(readModelType, out var sql)) { return sql; } var identityColumn = GetIdentityColumn(); var identityProperty = GetIdentityProperty(); var versionColumn = GetVersionColumn(); 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(); sql = $"UPDATE {tableName} SET {string.Join(", ", updateColumns)} WHERE {identityColumn} = @{identityProperty} {versionCheck}"; _updateSqls[readModelType] = sql; return sql; } public string CreatePurgeSql() where TReadModel : IReadModel { return _purgeSqls.GetOrCreate(typeof(TReadModel), t => $"DELETE FROM {GetTableName(t)}"); } public string GetTableName() where TReadModel : IReadModel { return GetTableName(typeof(TReadModel)); } private static string GetTableName(Type readModelType) { return TableNames.GetOrAdd( readModelType, t => { var tableAttribute = t.GetTypeInfo().GetCustomAttribute(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() { 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() { 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() { 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 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()); } }