using System.Data; using System.Diagnostics; using System.Reflection; using System.Text; using Dapper; namespace CrupestApi.Commons.Crud; /// /// Contains all you need to manipulate a table. /// public class TableInfo { private readonly IColumnTypeProvider _columnTypeProvider; private readonly Lazy> _lazyColumnNameList; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; public TableInfo(Type entityType, IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory) : this(entityType.Name, entityType, columnTypeProvider, loggerFactory) { } public TableInfo(string tableName, Type entityType, IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _logger.LogInformation("Create TableInfo for entity type '{}'.", entityType.Name); _columnTypeProvider = columnTypeProvider; TableName = tableName; EntityType = entityType; var properties = entityType.GetProperties(); _logger.LogInformation("Find following properties: {}", string.Join(", ", properties.Select(p => p.Name))); var columnInfos = new List(); bool hasId = false; ColumnInfo? primaryKeyColumn = null; ColumnInfo? keyColumn = null; List nonColumnProperties = new(); foreach (var property in properties) { _logger.LogInformation("Check property '{}'.", property.Name); if (CheckPropertyIsColumn(property)) { _logger.LogInformation("{} is a column, create ColumnInfo for it.", property.Name); var columnInfo = new ColumnInfo(this, property, _columnTypeProvider, _loggerFactory); columnInfos.Add(columnInfo); if (columnInfo.IsPrimaryKey) { _logger.LogInformation("Column {} is a primary key.", property.Name); primaryKeyColumn = columnInfo; } if (columnInfo.ColumnName.Equals("id", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Column {} has name id.", property.Name); hasId = true; } if (columnInfo.IsSpecifiedAsKey) { if (keyColumn is not null) { throw new Exception("Already exists a key column."); } _logger.LogInformation("Column {} is specified as key.", property.Name); keyColumn = columnInfo; } } else { _logger.LogInformation("{} is not a column.", property.Name); nonColumnProperties.Add(property); } } if (primaryKeyColumn is null) { if (hasId) throw new Exception("A column named id already exists but is not primary key."); _logger.LogInformation("No primary key column found, create one automatically."); primaryKeyColumn = CreateAutoIdColumn(); columnInfos.Add(primaryKeyColumn); } if (keyColumn is null) { _logger.LogInformation("No key column is specified, will use primary key."); keyColumn = primaryKeyColumn; } Columns = columnInfos; PrimaryKeyColumn = primaryKeyColumn; KeyColumn = keyColumn; NonColumnProperties = nonColumnProperties; _logger.LogInformation("Check table validity."); CheckValidity(); _logger.LogInformation("TableInfo succeeded to create."); _lazyColumnNameList = new Lazy>(() => Columns.Select(c => c.ColumnName).ToList()); } private ColumnInfo CreateAutoIdColumn() { return new ColumnInfo(this, new ColumnAttribute { ColumnName = "Id", NotNull = true, IsPrimaryKey = true, }, typeof(long), _columnTypeProvider, _loggerFactory); } public Type EntityType { get; } public string TableName { get; } public IReadOnlyList Columns { get; } public IReadOnlyList PropertyColumns => Columns.Where(c => c.PropertyInfo is not null).ToList(); public ColumnInfo PrimaryKeyColumn { get; } /// /// Maybe not the primary key. But acts as primary key. /// /// public ColumnInfo KeyColumn { get; } public IReadOnlyList ColumnProperties => PropertyColumns.Select(c => c.PropertyInfo!).ToList(); public IReadOnlyList NonColumnProperties { get; } public IReadOnlyList ColumnNameList => _lazyColumnNameList.Value; protected bool CheckPropertyIsColumn(PropertyInfo property) { var columnAttribute = property.GetCustomAttribute(); if (columnAttribute is null) return false; return true; } public ColumnInfo GetColumn(string columnName) { foreach (var column in Columns) { if (column.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)) { return column; } } throw new KeyNotFoundException("No such column with given name."); } public void CheckGeneratedColumnHasGenerator() { foreach (var column in Columns) { if (column.IsOnlyGenerated && column.DefaultValueGeneratorMethod is null) { throw new Exception($"Column '{column.ColumnName}' is generated but has no generator."); } } } public void CheckValidity() { // Check if there is only one primary key. bool hasPrimaryKey = false; bool hasKey = false; foreach (var column in Columns) { if (column.IsPrimaryKey) { if (hasPrimaryKey) throw new Exception("More than one columns are primary key."); hasPrimaryKey = true; } if (column.IsSpecifiedAsKey) { if (hasKey) throw new Exception("More than one columns are specified as key column."); } } if (!hasPrimaryKey) throw new Exception("No column is primary key."); // Check two columns have the same sql name. HashSet sqlNameSet = new HashSet(); foreach (var column in Columns) { if (sqlNameSet.Contains(column.ColumnName)) throw new Exception($"Two columns have the same sql name '{column.ColumnName}'."); sqlNameSet.Add(column.ColumnName); } CheckGeneratedColumnHasGenerator(); } public string GenerateCreateIndexSql(string? dbProviderId = null) { var sb = new StringBuilder(); foreach (var column in Columns) { if (column.Index == ColumnIndexType.None) continue; sb.Append($"CREATE {(column.Index == ColumnIndexType.Unique ? "UNIQUE" : "")} INDEX {TableName}_{column.ColumnName}_index ON {TableName} ({column.ColumnName});\n"); } return sb.ToString(); } public string GenerateCreateTableSql(bool createIndex = true, string? dbProviderId = null) { var tableName = TableName; var columnSql = string.Join(",\n", Columns.Select(c => c.GenerateCreateTableColumnString(dbProviderId))); var sql = $@" CREATE TABLE {tableName}( {columnSql} ); "; if (createIndex) { sql += GenerateCreateIndexSql(dbProviderId); } return sql; } public void CheckColumnName(string columnName) { if (!ColumnNameList.Contains(columnName)) { throw new ArgumentException($"Column {columnName} is not in the table."); } } public void CheckRelatedColumns(IClause? clause) { if (clause is not null) { var relatedColumns = clause.GetRelatedColumns(); foreach (var column in relatedColumns) { CheckColumnName(column); } } } /// /// If you call this manually, it's your duty to call hooks. /// /// public (string sql, ParamList parameters) GenerateSelectSql(string? selectWhat, IWhereClause? whereClause, IOrderByClause? orderByClause = null, int? skip = null, int? limit = null, string? dbProviderId = null) { CheckRelatedColumns(whereClause); CheckRelatedColumns(orderByClause); var parameters = new ParamList(); StringBuilder result = new StringBuilder() .Append($"SELECT {selectWhat ?? "*"} FROM ") .Append(TableName); if (whereClause is not null) { result.Append(" WHERE "); var (whereSql, whereParameters) = whereClause.GenerateSql(dbProviderId); parameters.AddRange(whereParameters); result.Append(whereSql); } if (orderByClause is not null) { result.Append(' '); var orderBySql = orderByClause.GenerateSql(dbProviderId); result.Append(orderBySql); } if (limit is not null) { result.Append(" LIMIT @Limit"); parameters.Add("Limit", limit.Value); } if (skip is not null) { result.Append(" OFFSET @Skip"); parameters.Add("Skip", skip.Value); } result.Append(';'); return (result.ToString(), parameters); } /// /// If you call this manually, it's your duty to call hooks. /// /// public (string sql, ParamList parameters) GenerateInsertSql(IInsertClause insertClause, string? dbProviderId = null) { CheckRelatedColumns(insertClause); var parameters = new ParamList(); var result = new StringBuilder() .Append("INSERT INTO ") .Append(TableName) .Append(" (") .Append(insertClause.GenerateColumnListSql(dbProviderId)) .Append(") VALUES ("); var (valueSql, valueParameters) = insertClause.GenerateValueListSql(dbProviderId); result.Append(valueSql).Append(");"); parameters.AddRange(valueParameters); return (result.ToString(), parameters); } /// /// If you call this manually, it's your duty to call hooks. /// /// public (string sql, ParamList parameters) GenerateUpdateSql(IWhereClause? whereClause, IUpdateClause updateClause) { CheckRelatedColumns(whereClause); CheckRelatedColumns(updateClause); var parameters = new ParamList(); StringBuilder sb = new StringBuilder("UPDATE "); sb.Append(TableName); sb.Append(" SET "); var (updateSql, updateParameters) = updateClause.GenerateSql(); sb.Append(updateSql); parameters.AddRange(updateParameters); if (whereClause is not null) { sb.Append(" WHERE "); var (whereSql, whereParameters) = whereClause.GenerateSql(); sb.Append(whereSql); parameters.AddRange(whereParameters); } sb.Append(';'); return (sb.ToString(), parameters); } /// /// If you call this manually, it's your duty to call hooks. /// /// public (string sql, ParamList parameters) GenerateDeleteSql(IWhereClause? whereClause) { CheckRelatedColumns(whereClause); var parameters = new ParamList(); StringBuilder sb = new StringBuilder("DELETE FROM "); sb.Append(TableName); if (whereClause is not null) { sb.Append(" WHERE "); var (whereSql, whereParameters) = whereClause.GenerateSql(); parameters.AddRange(whereParameters); sb.Append(whereSql); } sb.Append(';'); return (sb.ToString(), parameters); } private DynamicParameters ConvertParameters(ParamList parameters) { var result = new DynamicParameters(); foreach (var param in parameters) { if (param.Value is null || param.Value is DbNullValue) { result.Add(param.Name, null); continue; } var columnName = param.ColumnName; IColumnTypeInfo typeInfo; if (columnName is not null) { typeInfo = GetColumn(columnName).ColumnType; } else { typeInfo = _columnTypeProvider.Get(param.Value.GetType()); } result.Add(param.Name, typeInfo.ConvertToDatabase(param.Value), typeInfo.DbType); } return result; } /// /// ConvertParameters. Select. Call hooks. /// public virtual List SelectDynamic(IDbConnection dbConnection, string? what = null, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null) { var (sql, parameters) = GenerateSelectSql(what, where, orderBy, skip, limit); var queryResult = dbConnection.Query(sql, ConvertParameters(parameters)); return queryResult.ToList(); } public virtual int SelectCount(IDbConnection dbConnection, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null) { var (sql, parameters) = GenerateSelectSql("COUNT(*)", where, orderBy, skip, limit); var result = dbConnection.QuerySingle(sql, ConvertParameters(parameters)); return result; } public virtual TResult MapDynamicTo(dynamic d) { var dict = (IDictionary)d; var result = Activator.CreateInstance(); Type resultType = typeof(TResult); foreach (var column in Columns) { var resultProperty = resultType.GetProperty(column.ColumnName); if (dict.ContainsKey(column.ColumnName) && resultProperty is not null) { if (dict[column.ColumnName] is null) { resultProperty.SetValue(result, null); continue; } object? value = Convert.ChangeType(dict[column.ColumnName], column.ColumnType.DatabaseClrType); value = column.ColumnType.ConvertFromDatabase(value); resultProperty.SetValue(result, value); } } return result; } /// /// Select and call hooks. /// public virtual List Select(IDbConnection dbConnection, string? what = null, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null) { List queryResult = SelectDynamic(dbConnection, what, where, orderBy, skip, limit).ToList(); return queryResult.Select(MapDynamicTo).ToList(); } public IInsertClause ConvertEntityToInsertClause(object entity) { Debug.Assert(EntityType.IsInstanceOfType(entity)); var result = new InsertClause(); foreach (var column in PropertyColumns) { var value = column.PropertyInfo!.GetValue(entity); result.Add(column.ColumnName, value); } return result; } /// /// Insert a entity and call hooks. /// /// The key of insert entity. public int Insert(IDbConnection dbConnection, IInsertClause insert, out object key) { object? finalKey = null; var realInsert = InsertClause.Create(); foreach (var column in Columns) { InsertItem? item = insert.Items.SingleOrDefault(i => i.ColumnName == column.ColumnName); var value = item?.Value; if (column.IsOnlyGenerated && value is not null) { throw new Exception($"The column '{column.ColumnName}' is auto generated. You can't specify it explicitly."); } if (value is null) { value = column.GenerateDefaultValue(); } if (value is null && column.IsAutoIncrement) { continue; } if (value is null) { value = DbNullValue.Instance; } column.InvokeValidator(value); InsertItem realInsertItem; if (value is DbNullValue) { if (column.IsNotNull) { throw new Exception($"Column '{column.ColumnName}' is not nullable. Please specify a non-null value."); } realInsertItem = new InsertItem(column.ColumnName, null); } else { realInsertItem = new InsertItem(column.ColumnName, value); } realInsert.Add(realInsertItem); if (realInsertItem.ColumnName == KeyColumn.ColumnName) { finalKey = realInsertItem.Value; } } if (finalKey is null) throw new Exception("No key???"); key = finalKey; var (sql, parameters) = GenerateInsertSql(realInsert); var affectedRowCount = dbConnection.Execute(sql, ConvertParameters(parameters)); if (affectedRowCount != 1) throw new Exception("Failed to insert."); return affectedRowCount; } /// /// Upgrade a entity and call hooks. /// /// The key of insert entity. public virtual int Update(IDbConnection dbConnection, IWhereClause? where, IUpdateClause update, out object? newKey) { newKey = null; var realUpdate = UpdateClause.Create(); foreach (var column in Columns) { UpdateItem? item = update.Items.FirstOrDefault(i => i.ColumnName == column.ColumnName); object? value = item?.Value; if (value is not null) { if (column.IsNoUpdate) { throw new Exception($"The column '{column.ColumnName}' can't be update."); } column.InvokeValidator(value); realUpdate.Add(column.ColumnName, value); if (column.ColumnName == KeyColumn.ColumnName) { newKey = value; } } } var (sql, parameters) = GenerateUpdateSql(where, realUpdate); return dbConnection.Execute(sql, ConvertParameters(parameters)); } public virtual int Delete(IDbConnection dbConnection, IWhereClause? where) { var (sql, parameters) = GenerateDeleteSql(where); return dbConnection.Execute(sql, ConvertParameters(parameters)); } } public interface ITableInfoFactory { TableInfo Get(Type type); } public class TableInfoFactory : ITableInfoFactory { private readonly Dictionary _cache = new Dictionary(); private readonly IColumnTypeProvider _columnTypeProvider; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; public TableInfoFactory(IColumnTypeProvider columnTypeProvider, ILoggerFactory loggerFactory) { _columnTypeProvider = columnTypeProvider; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); } // This is thread-safe. public TableInfo Get(Type type) { lock (_cache) { if (_cache.TryGetValue(type, out var tableInfo)) { _logger.LogDebug("Table info of type '{}' is cached, return it.", type.Name); return tableInfo; } else { _logger.LogDebug("Table info for type '{}' is not in cache, create it.", type.Name); tableInfo = new TableInfo(type, _columnTypeProvider, _loggerFactory); _logger.LogDebug("Table info for type '{}' is created, add it to cache.", type.Name); _cache.Add(type, tableInfo); return tableInfo; } } } }