Nueva mejoras Y estabilidad

This commit is contained in:
2025-12-26 22:27:20 -06:00
parent 203859b22a
commit ac96cb1f23
23 changed files with 1841 additions and 480 deletions

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using Dapper;
namespace MicroORM;
public static class DapperTypeMapRegistrar
{
public static void RegisterFor(Type type)
{
SqlMapper.SetTypeMap(
type,
new CustomPropertyTypeMap(
type,
(t, columnName) =>
{
// [Column]
var prop = t.GetProperties()
.FirstOrDefault(p =>
p.GetCustomAttribute<ColumnAttribute>()?.Name
.Equals(columnName, StringComparison.OrdinalIgnoreCase) == true
);
if (prop != null)
return prop;
// Directo
prop = t.GetProperty(columnName,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (prop != null)
return prop;
// snake_case
var pascal = string.Concat(
columnName.Split('_')
.Select(s => char.ToUpperInvariant(s[0]) + s[1..])
);
return t.GetProperty(pascal,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
)
);
}
}

View File

@@ -0,0 +1,18 @@
using System.Data;
using System.Data.Common;
namespace MicroORM;
public static class DbConnectionExtensions
{
public static async Task EnsureOpenAsync(this IDbConnection connection)
{
if (connection.State != ConnectionState.Open)
{
if (connection is DbConnection db)
await db.OpenAsync();
else
connection.Open();
}
}
}

View File

@@ -0,0 +1,7 @@
namespace MicroORM.Exceptions;
public class OrmException : Exception
{
public OrmException(string message, Exception? inner = null)
: base(message, inner) { }
}

View File

@@ -0,0 +1,68 @@
using System.Linq.Expressions;
using System.Reflection;
using Dapper;
namespace MicroORM;
public static class ExpressionToSqlTranslator
{
public static string Translate<T>(
Expression<Func<T, bool>> expression,
DynamicParameters parameters)
{
return Visit(expression.Body, parameters);
}
private static string Visit(Expression exp, DynamicParameters parameters)
{
return exp switch
{
BinaryExpression b => VisitBinary(b, parameters),
MemberExpression m => GetColumnName(m),
ConstantExpression c => AddParameter(c, parameters),
UnaryExpression u => Visit(u.Operand, parameters),
_ => throw new NotSupportedException($"Expression no soportada: {exp.NodeType}")
};
}
private static string VisitBinary(BinaryExpression b, DynamicParameters parameters)
{
var left = Visit(b.Left, parameters);
var right = Visit(b.Right, parameters);
var op = b.NodeType switch
{
ExpressionType.Equal => "=",
ExpressionType.NotEqual => "<>",
ExpressionType.GreaterThan => ">",
ExpressionType.GreaterThanOrEqual => ">=",
ExpressionType.LessThan => "<",
ExpressionType.LessThanOrEqual => "<=",
ExpressionType.AndAlso => "AND",
ExpressionType.OrElse => "OR",
_ => throw new NotSupportedException($"Operador no soportado: {b.NodeType}")
};
return $"({left} {op} {right})";
}
private static string GetColumnName(MemberExpression m)
{
var prop = (PropertyInfo)m.Member;
return OrmMetadata.GetColumnName(prop);
}
private static string AddParameter(ConstantExpression c, DynamicParameters parameters)
{
var name = $"@p{parameters.ParameterNames.Count()}";
parameters.Add(name, c.Value);
return name;
}
public static string GetOrderByColumn<T>(Expression<Func<T, object>> exp)
{
var body = exp.Body is UnaryExpression u ? u.Operand : exp.Body;
var member = (MemberExpression)body;
return OrmMetadata.GetColumnName((PropertyInfo)member.Member);
}
}

View File

@@ -0,0 +1,20 @@
using System.Data;
using System.Linq.Expressions;
namespace MicroORM.Interfaces;
public interface IRepository<T>
{
Task<int> InsertAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null);
Task<bool> UpdateAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null);
Task<T?> GetByIdAsync(object id);
Task<IEnumerable<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null,
Expression<Func<T, object>>? orderBy = null, bool descending = false);
Task<bool> DeleteAsync(object id);
Task<int> SaveAsync<T>(T entity);
Task<IReadOnlyList<int>> SaveAsyncList<T>(IEnumerable<T> entities);
Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null);
Task<IEnumerable<T>> QueryAsync<T>(string sql, object? parameters = null);
Task<T?> ExecuteScalarAsync<T>(string sql, object? parameters = null);
}

7
MicroORM/MicroORM.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace MicroORM;
public class MicroORM
{
}

15
MicroORM/MicroORM.csproj Normal file
View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dapper.Contrib" Version="2.0.78" />
<PackageReference Include="Dapper.SqlBuilder" Version="2.1.66" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Npgsql.Json.NET" Version="10.0.1" />
</ItemGroup>
</Project>

63
MicroORM/OrmMetadata.cs Normal file
View File

@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
namespace MicroORM;
public class OrmMetadata
{
public static string GetTableName<T>()
{
var table = typeof(T).GetCustomAttribute<TableAttribute>();
if (table == null)
throw new InvalidOperationException("La entidad no tiene [Table]");
return string.IsNullOrEmpty(table.Schema)
? table.Name
: $"{table.Schema}.{table.Name}";
}
public static PropertyInfo GetKeyProperty<T>()
{
var key = typeof(T)
.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (key == null)
throw new InvalidOperationException("La entidad no tiene [Key]");
return key;
}
public static string GetColumnName(PropertyInfo prop)
{
var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
return columnAttr?.Name ?? prop.Name;
}
public static bool IsIdentity(PropertyInfo prop)
{
var key = prop.GetCustomAttribute<KeyAttribute>() != null;
var dbGenerated = prop.GetCustomAttribute<DatabaseGeneratedAttribute>();
return key &&
dbGenerated?.DatabaseGeneratedOption == DatabaseGeneratedOption.Identity;
}
public static IEnumerable<PropertyInfo> GetScaffoldProperties<T>()
{
var type = typeof(T);
var key = GetKeyProperty<T>();
return type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p =>
p.CanWrite && // tiene setter
p.GetCustomAttribute<NotMappedAttribute>() == null &&
(
p.GetCustomAttribute<ColumnAttribute>() != null ||
p == key
)
);
}
}

610
MicroORM/Repository.cs Normal file
View File

@@ -0,0 +1,610 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data;
using System.Linq.Expressions;
using System.Reflection;
using Dapper;
using MicroORM.Exceptions;
using MicroORM.Interfaces;
namespace MicroORM;
public class Repository<T>: IRepository<T>
{
private readonly Func<Task<IDbConnection>> _connectionFactory;
private IRepository<T> _repositoryImplementation;
public Repository(Func<Task<IDbConnection>> connectionFactory)
{
_connectionFactory = connectionFactory;
DapperTypeMapRegistrar.RegisterFor(typeof(T));
}
static Repository()
{
DapperTypeMapRegistrar.RegisterFor(typeof(T));
}
public async Task<int> SaveAsync<T>(T entity)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
using var tx = conn.BeginTransaction();
try
{
await ResolveRelationsAsync(entity, conn, tx);
int id;
if (GetId(entity) == 0)
id = await InsertAsync(entity, conn, tx);
else
{
await UpdateAsync(entity, conn, tx);
id = GetId(entity);
}
tx.Commit();
return id;
}
catch
{
tx.Rollback();
throw;
}
}
public async Task<IReadOnlyList<int>> SaveAsyncList<T>(IEnumerable<T> entities)
{
if (entities == null)
throw new ArgumentNullException(nameof(entities));
var list = entities as IList<T> ?? entities.ToList();
if (list.Count == 0)
return Array.Empty<int>();
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
using var tx = conn.BeginTransaction();
try
{
var ids = new List<int>(list.Count);
foreach (var entity in list)
{
if (entity == null)
throw new OrmException("La lista contiene una entidad nula");
await ResolveRelationsAsync(entity, conn, tx);
int id;
if (GetId(entity) == 0)
{
id = await InsertAsync(entity, conn, tx);
SetId(entity, id); // MUY IMPORTANTE
}
else
{
await UpdateAsync(entity, conn, tx);
id = GetId(entity);
}
ids.Add(id);
}
tx.Commit();
return ids;
}
catch
{
tx.Rollback();
throw;
}
}
public async Task<int> InsertAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
bool ownsConnection = connection == null;
bool ownsTransaction = transaction == null;
IDbConnection? conn = connection;
IDbTransaction? tx = transaction;
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var props = OrmMetadata.GetScaffoldProperties<T>().Where(p => !OrmMetadata.IsIdentity(p)).ToArray();
if (!props.Any())
throw new OrmException($"No hay columnas insertables para {typeof(T).Name}");
var columns = props.Select(p => OrmMetadata.GetColumnName(p));
var parameters = props.Select(p => "@" + p.Name);
var sql = $@"
INSERT INTO {table}
({string.Join(", ", columns)})
VALUES ({string.Join(", ", parameters)})
RETURNING {OrmMetadata.GetColumnName(key)};
";
if (conn == null)
{
conn = await _connectionFactory();
await conn.EnsureOpenAsync();
}
if (tx == null)
{
tx = conn.BeginTransaction();
}
var id = await conn.ExecuteScalarAsync<int>(
sql,
entity,
tx
);
if (ownsTransaction)
tx.Commit();
return id;
}
catch (Exception ex)
{
if (ownsTransaction)
try { tx?.Rollback(); } catch { /* swallow */ }
throw new OrmException(
$"Error INSERT en {typeof(T).Name}",
ex
);
}
finally
{
if (ownsTransaction)
tx?.Dispose();
if (ownsConnection)
conn?.Dispose();
}
}
public async Task<bool> UpdateAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
bool ownsConnection = connection == null;
bool ownsTransaction = transaction == null;
IDbConnection? conn = connection;
IDbTransaction? tx = transaction;
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var keyValue = key.GetValue(entity);
if (keyValue == null)
throw new OrmException($"La entidad {typeof(T).Name} no tiene valor en la clave primaria");
var props = OrmMetadata.GetScaffoldProperties<T>().Where(p => !OrmMetadata.IsIdentity(p)).ToArray();
if (!props.Any())
return false;
var sets = props.Select(p =>
$"{OrmMetadata.GetColumnName(p)} = @{p.Name}");
var sql = $@"
UPDATE {table}
SET {string.Join(", ", sets)}
WHERE {OrmMetadata.GetColumnName(key)} = @{key.Name};
";
if (conn == null)
{
conn = await _connectionFactory();
await conn.EnsureOpenAsync();
}
if (tx == null)
{
tx = conn.BeginTransaction();
}
var affected = await conn.ExecuteAsync(
sql,
entity,
tx
);
if (ownsTransaction)
tx.Commit();
return affected > 0;
}
catch (Exception ex)
{
if (ownsTransaction)
try { tx?.Rollback(); } catch { /* ignore */ }
throw new OrmException(
$"Error UPDATE en {typeof(T).Name}",
ex
);
}
finally
{
if (ownsTransaction)
tx?.Dispose();
if (ownsConnection)
conn?.Dispose();
}
}
public async Task<T?> GetByIdAsync(object id)
{
if (id == null)
throw new ArgumentNullException(nameof(id));
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var sql = $@"
SELECT *
FROM {table}
WHERE {OrmMetadata.GetColumnName(key)} = @Id;
";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryFirstOrDefaultAsync<T>(sql, new { Id = id });
}
catch (Exception ex)
{
throw new OrmException(
$"Error SELECT por ID en {typeof(T).Name}",
ex
);
}
}
public async Task<IEnumerable<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null, Expression<Func<T, object>>? orderBy = null, bool descending = false)
{
try
{
var table = OrmMetadata.GetTableName<T>();
var parameters = new DynamicParameters();
var sql = $"SELECT * FROM {table}";
// WHERE dinámico
if (filter != null)
{
var where = ExpressionToSqlTranslator.Translate(filter, parameters);
sql += $" WHERE {where}";
}
// ORDER BY dinámico
if (orderBy != null)
{
var column = ExpressionToSqlTranslator.GetOrderByColumn(orderBy);
sql += $" ORDER BY {column} {(descending ? "DESC" : "ASC")}";
}
sql += ";";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryAsync<T>(sql, parameters);
}
catch (Exception ex)
{
throw new OrmException(
$"Error SELECT dinámico en {typeof(T).Name}",
ex
);
}
}
public async Task<bool> DeleteAsync(object id)
{
if (id == null)
throw new ArgumentNullException(nameof(id));
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var sql = $@"
DELETE FROM {table}
WHERE {OrmMetadata.GetColumnName(key)} = @Id;
";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.ExecuteAsync(sql, new { Id = id }) > 0;
}
catch (Exception ex)
{
throw new OrmException(
$"Error DELETE en {typeof(T).Name}",
ex
);
}
}
public async Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryFirstOrDefaultAsync<T>(
sql,
parameters
);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en QuerySingle<{typeof(T).Name}>",
ex
);
}
}
public async Task<IEnumerable<T>> QueryAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryAsync<T>(sql, parameters);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en Query<{typeof(T).Name}>",
ex
);
}
}
public async Task<T?> ExecuteScalarAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.ExecuteScalarAsync<T>(
sql,
parameters
);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en ExecuteScalar<{typeof(T).Name}>",
ex
);
}
}
private async Task ResolveRelationsAsync<T>(
T entity,
IDbConnection conn,
IDbTransaction tx)
{
var navProps = typeof(T)
.GetProperties()
.Where(p =>
p.PropertyType.IsClass &&
p.PropertyType != typeof(string) &&
p.GetCustomAttribute<NotMappedAttribute>() == null);
foreach (var nav in navProps)
{
var related = nav.GetValue(entity);
if (related == null)
continue;
var relatedType = nav.PropertyType;
var relatedId = GetId(related);
if (relatedId == 0)
relatedId = await InsertDynamicAsync(related, conn, tx);
else
await UpdateDynamicAsync(related, conn, tx);
SetForeignKey(entity, nav, relatedId);
}
}
private void SetForeignKey<T>(
T entity,
PropertyInfo navProp,
int fkValue)
{
var fkAttr = navProp.GetCustomAttribute<ForeignKeyAttribute>();
if (fkAttr != null)
{
var fkProp = typeof(T).GetProperty(fkAttr.Name);
fkProp?.SetValue(entity, fkValue);
return;
}
var fkName = navProp.Name + "Id";
var prop = typeof(T).GetProperty(fkName);
if (prop == null)
throw new OrmException($"No se pudo resolver FK para {navProp.Name}");
prop.SetValue(entity, fkValue);
}
private async Task<int> InsertDynamicAsync(
object entity,
IDbConnection conn,
IDbTransaction tx)
{
var method = typeof(Repository<>)
.GetMethod(nameof(InsertAsync))!
.MakeGenericMethod(entity.GetType());
return await (Task<int>)method.Invoke(this, new[] { entity, conn, tx })!;
}
private async Task<int> UpdateDynamicAsync(
object entity,
IDbConnection conn,
IDbTransaction tx)
{
var method = typeof(Repository<>)
.GetMethod(nameof(UpdateAsync))!
.MakeGenericMethod(entity.GetType());
return await (Task<int>)method.Invoke(this, new[] { entity, conn, tx })!;
}
private static int GetId(object entity)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
var type = entity.GetType();
var keyProp = type.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (keyProp == null)
{
keyProp = type.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? type.GetProperty(type.Name + "Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
if (keyProp == null)
throw new OrmException($"No se encontró la propiedad clave (Key/Id/{type.Name}Id) en el tipo {type.FullName}");
var value = keyProp.GetValue(entity);
if (value == null) return 0;
if (value is int i) return i;
if (value is long l)
{
if (l > int.MaxValue || l < int.MinValue)
throw new OrmException($"El valor de la clave primaria ({l}) excede el rango de int para {type.FullName}");
return (int)l;
}
try
{
var converted = Convert.ToInt32(value);
return converted;
}
catch (Exception)
{
if (value is Guid)
throw new OrmException($"La clave primaria de {type.FullName} es un GUID; el flujo actual espera claves numéricas. Ajusta el método si deseas soportar GUIDs.");
throw new OrmException($"No fue posible convertir la clave primaria de {type.FullName} (tipo {value.GetType().Name}) a int.");
}
}
private static void SetId(object entity, int id)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
var type = entity.GetType();
// Buscar la propiedad clave (igual que en GetId)
var keyProp = type.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (keyProp == null)
{
keyProp = type.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? type.GetProperty(type.Name + "Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
if (keyProp == null)
throw new OrmException($"No se encontró la propiedad clave (Key/Id/{type.Name}Id) en el tipo {type.FullName}");
if (!keyProp.CanWrite)
throw new OrmException($"La propiedad clave {keyProp.Name} en {type.FullName} no tiene setter público.");
var targetType = Nullable.GetUnderlyingType(keyProp.PropertyType) ?? keyProp.PropertyType;
object valueToSet;
if (targetType == typeof(int))
{
valueToSet = id;
}
else if (targetType == typeof(long))
{
valueToSet = (long)id;
}
else if (targetType == typeof(short))
{
valueToSet = (short)id;
}
else if (targetType == typeof(byte))
{
valueToSet = (byte)id;
}
else if (targetType == typeof(Guid))
{
throw new OrmException($"La clave primaria de {type.FullName} es GUID; SetId con int no es aplicable.");
}
else
{
try
{
valueToSet = Convert.ChangeType(id, targetType);
}
catch (Exception ex)
{
throw new OrmException($"No fue posible convertir int a {targetType.Name} para la clave de {type.FullName}.", ex);
}
}
keyProp.SetValue(entity, valueToSet);
}
}