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