Gesgocom.MicroOrmGesg 1.4.1
MicroOrmGesg
Micro ORM para PostgreSQL basado en Dapper y Npgsql. Proporciona CRUD genérico, queries directas, ejecución de funciones, migraciones idempotentes y logging integrado.
Tabla de contenidos
🚀 Inicio rápido
📚 Casos de uso
- ¿Cuándo usar qué? - Guía de decisión
- CRUD simple con repositorio genérico
- Queries SQL personalizadas
- Transacciones y operaciones complejas
- Ejecutar funciones PostgreSQL
- Trabajo con JSONB
- Migraciones de base de datos
🔧 Referencia completa
- DbSession: Conexiones y transacciones
- IDataMicroOrm: Repositorio genérico
- IDirectQuery: Queries directas con Dapper
- IDataFunctions: Funciones PostgreSQL
- Sistema de migraciones
- Row Level Security (multi-inquilino) — incluye guía de migración
- Logging y diagnóstico
- Atributos de mapeo
- Paginación y filtrado
- SqlLiteralFormatter: literales SQL seguros
📖 Recursos adicionales
🚀 Inicio rápido
Instalación
dotnet add package Gesgocom.MicroOrmGesg
O compilar localmente:
dotnet build --configuration Release
Setup inicial
1. Instalar paquete NuGet:
dotnet add package Gesgocom.MicroOrmGesg
# Npgsql, Dapper y Newtonsoft.Json se instalan automáticamente como dependencias
2. Registrar servicios en Program.cs:
using MicroOrmGesg.Interfaces;
using MicroOrmGesg.Repository;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// 1. NpgsqlDataSource (pool de conexiones)
builder.Services.AddSingleton(sp =>
{
var connectionString = builder.Configuration.GetConnectionString("PostgreSQL")!;
var dsBuilder = new NpgsqlDataSourceBuilder(connectionString);
// dsBuilder.UseNodaTime(); // Descomentar si usas NodaTime
return dsBuilder.Build();
});
// 2. DbSession para conexiones y transacciones
builder.Services.AddScoped<IDbSession, DbSession>();
// 3. Repositorio genérico
builder.Services.AddScoped(typeof(IDataMicroOrm<>), typeof(DataMicroOrmRepository<>));
// 4. Queries directas con Dapper (habilita snake_case → PascalCase automáticamente)
builder.Services.AddScoped<IDirectQuery, DirectQuery>();
// 5. Ejecutor de funciones PostgreSQL
builder.Services.AddScoped<IDataFunctions, DataFunctionsRepository>();
// 6. Compatibilidad de tipos (opcional, según tus modelos)
// NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility(); // Si usas DateTime con date/time
// NodaTimeCompatibility.EnableDateTimeCompatibility(); // Si usas DateTime con UseNodaTime()
// 7. Logging (opcional pero recomendado)
builder.Logging.AddConsole();
builder.Logging.AddFilter("MicroOrmGesg", LogLevel.Information);
var app = builder.Build();
app.Run();
3. Configurar connection string en appsettings.json:
{
"ConnectionStrings": {
"PostgreSQL": "Host=localhost;Database=mydb;Username=user;Password=pass"
}
}
Tu primer CRUD
1. Define tu entidad:
using MicroOrmGesg;
// PostgreSQL: CREATE TYPE rol_enum AS ENUM ('Admin','Editor','Lector');
public enum Rol { Admin, Editor, Lector }
[Table("usuarios")]
public class Usuario
{
[Key]
public int Id { get; set; }
public string Nombre { get; set; } = null!;
public string Email { get; set; } = null!;
[PgEnum] // Enum PostgreSQL → se envía como texto
public Rol Rol { get; set; }
[SoftDelete]
public bool Eliminado { get; set; }
}
2. Crea un servicio:
public class UsuarioService
{
private readonly IDbSession _db;
private readonly IDataMicroOrm<Usuario> _repo;
public UsuarioService(IDbSession db, IDataMicroOrm<Usuario> repo)
{
_db = db;
_repo = repo;
}
public async Task<Usuario?> ObtenerAsync(int id, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.GetByIdAsync(_db, id, ct);
}
public async Task<int> CrearAsync(Usuario usuario, CancellationToken ct)
{
await _db.OpenAsync(ct);
await _db.BeginTransactionAsync(ct: ct);
try
{
var id = await _repo.InsertAsyncReturnId(_db, usuario, ct);
await _db.CommitAsync(ct);
return (int)id!;
}
catch
{
await _db.RollbackAsync(ct);
throw;
}
}
}
3. Úsalo en un controlador:
[ApiController]
[Route("api/[controller]")]
public class UsuariosController : ControllerBase
{
private readonly UsuarioService _service;
public UsuariosController(UsuarioService service) => _service = service;
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var usuario = await _service.ObtenerAsync(id, ct);
return usuario is null ? NotFound() : Ok(usuario);
}
[HttpPost]
public async Task<IActionResult> Post(Usuario usuario, CancellationToken ct)
{
var id = await _service.CrearAsync(usuario, ct);
return CreatedAtAction(nameof(Get), new { id }, usuario);
}
}
✅ ¡Listo! Ya tienes CRUD funcional con transacciones, soft delete y logging.
📚 Casos de uso
¿Cuándo usar qué?
| Necesitas... | Usa | Ejemplo |
|---|---|---|
| CRUD simple de una tabla | IDataMicroOrm<T> |
await _repo.GetByIdAsync(...) |
| Query con JOINs o subconsultas | IDirectQuery |
await _query.QueryAsync<Dto>("SELECT...") |
| Llamar función/stored procedure | IDataFunctions |
await _funcs.CallFunctionAsync(...) |
| Operación con múltiples tablas | IDirectQuery + Transacción |
Ver Caso 3 |
| Paginación y filtros simples | IDataMicroOrm<T> |
await _repo.PageAsync(...) |
| Migrar esquema de base de datos | IPgMigrator |
Ver Caso 6 |
Caso 1: CRUD simple
Problema: Necesito operaciones básicas (crear, leer, actualizar, eliminar) en una tabla.
Solución: Usa IDataMicroOrm<T> (repositorio genérico)
public class ProductoService
{
private readonly IDbSession _db;
private readonly IDataMicroOrm<Producto> _repo;
// Leer
public async Task<Producto?> ObtenerAsync(int id, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.GetByIdAsync(_db, id, ct);
}
// Listar con paginación
public async Task<Page<Producto>> ListarAsync(int page, int size, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.PageAsync(_db, page, size, ct: ct);
}
// Crear
public async Task<int> CrearAsync(Producto producto, CancellationToken ct)
{
await _db.OpenAsync(ct);
var id = await _repo.InsertAsyncReturnId(_db, producto, ct);
return (int)id!;
}
// Actualizar
public async Task<bool> ActualizarAsync(Producto producto, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.UpdateAsync(_db, producto, ct);
}
// Actualización parcial
public async Task<bool> ActualizarPrecioAsync(int id, decimal nuevoPrecio, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.UpdateSetAsync(_db, id, new { precio = nuevoPrecio }, ct);
}
// Eliminar (soft delete si la entidad lo tiene configurado)
public async Task<bool> EliminarAsync(int id, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.DeleteAsync(_db, id, ct);
}
}
Ventajas:
- No escribes SQL
- Convenciones automáticas (snake_case)
- Soft delete automático
- Type-safe
Ver más: Referencia IDataMicroOrm
Caso 2: Queries personalizadas
Problema: Necesito hacer un JOIN, una subconsulta o SQL específico.
Solución: Usa IDirectQuery para SQL directo con Dapper
public class ReporteService
{
private readonly IDbSession _db;
private readonly IDirectQuery _query;
// Query con JOIN
public async Task<List<UsuarioConPedidosDto>> ObtenerUsuariosConPedidosAsync(CancellationToken ct)
{
await _db.OpenAsync(ct);
const string sql = @"
SELECT
u.id,
u.nombre,
u.email,
COUNT(p.id) as total_pedidos,
SUM(p.total) as total_gastado
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
WHERE u.eliminado = false
GROUP BY u.id, u.nombre, u.email
HAVING COUNT(p.id) > 0
ORDER BY total_gastado DESC
LIMIT 100";
return (await _query.QueryAsync<UsuarioConPedidosDto>(_db, sql, ct: ct)).ToList();
}
// Query con parámetros
public async Task<PedidoDetalleDto?> ObtenerDetallePedidoAsync(int pedidoId, CancellationToken ct)
{
await _db.OpenAsync(ct);
const string sql = @"
SELECT
p.id,
p.fecha,
p.total,
u.nombre as nombre_usuario,
json_agg(json_build_object(
'producto', prod.nombre,
'cantidad', dp.cantidad,
'precio', dp.precio_unitario
)) as items
FROM pedidos p
INNER JOIN usuarios u ON u.id = p.usuario_id
LEFT JOIN detalle_pedido dp ON dp.pedido_id = p.id
LEFT JOIN productos prod ON prod.id = dp.producto_id
WHERE p.id = @pedidoId
GROUP BY p.id, p.fecha, p.total, u.nombre";
return await _query.QuerySingleOrDefaultAsync<PedidoDetalleDto>(
_db, sql, new { pedidoId }, ct);
}
// Múltiples result sets
public async Task<DashboardDto> ObtenerDashboardAsync(int usuarioId, CancellationToken ct)
{
await _db.OpenAsync(ct);
const string sql = @"
-- Result set 1: Usuario
SELECT id, nombre, email FROM usuarios WHERE id = @usuarioId;
-- Result set 2: Pedidos recientes
SELECT id, fecha, total FROM pedidos
WHERE usuario_id = @usuarioId
ORDER BY fecha DESC LIMIT 5;
-- Result set 3: Estadísticas
SELECT COUNT(*) as total_pedidos, SUM(total) as total_gastado
FROM pedidos WHERE usuario_id = @usuarioId";
await using var multi = await _query.QueryMultipleAsync(_db, sql, new { usuarioId }, ct);
var usuario = await multi.ReadSingleAsync<UsuarioDto>();
var pedidos = (await multi.ReadAsync<PedidoDto>()).ToList();
var stats = await multi.ReadSingleAsync<StatsDto>();
return new DashboardDto(usuario, pedidos, stats);
}
}
Ventajas:
- Flexibilidad total (cualquier SQL)
- JOINs, CTEs, window functions
- Comparte conexión/transacción con el repositorio
Ver más: Referencia IDirectQuery
Caso 3: Transacciones
Problema: Necesito que varias operaciones se ejecuten atómicamente (todo o nada).
Solución: Usa IDbSession.BeginTransactionAsync() + Commit/Rollback
public class PedidoService
{
private readonly IDbSession _db;
private readonly IDataMicroOrm<Pedido> _pedidoRepo;
private readonly IDirectQuery _query;
public async Task<int> CrearPedidoAsync(CrearPedidoDto dto, CancellationToken ct)
{
await _db.OpenAsync(ct);
await _db.BeginTransactionAsync(ct: ct);
try
{
// 1. Crear pedido
var pedido = new Pedido
{
UsuarioId = dto.UsuarioId,
Fecha = DateTime.UtcNow,
Total = dto.Items.Sum(i => i.Precio * i.Cantidad)
};
var pedidoId = (int)(await _pedidoRepo.InsertAsyncReturnId(_db, pedido, ct))!;
// 2. Insertar items del pedido
foreach (var item in dto.Items)
{
const string insertItem = @"
INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (@pedidoId, @productoId, @cantidad, @precio)";
await _query.ExecuteAsync(_db, insertItem, new
{
pedidoId,
productoId = item.ProductoId,
cantidad = item.Cantidad,
precio = item.Precio
}, ct);
}
// 3. Actualizar stock de productos
foreach (var item in dto.Items)
{
const string updateStock = @"
UPDATE productos
SET stock = stock - @cantidad
WHERE id = @productoId AND stock >= @cantidad";
var rowsAffected = await _query.ExecuteAsync(_db, updateStock, new
{
productoId = item.ProductoId,
cantidad = item.Cantidad
}, ct);
if (rowsAffected == 0)
throw new InvalidOperationException($"Stock insuficiente para producto {item.ProductoId}");
}
// 4. Confirmar transacción
await _db.CommitAsync(ct);
return pedidoId;
}
catch
{
// Rollback automático en caso de error
await _db.RollbackAsync(ct);
throw;
}
}
}
Ventajas:
- Atomicidad garantizada
- Rollback automático en excepciones
- Puedes mezclar repositorio + queries directas
Ver más: Referencia DbSession
Caso 4: Funciones PostgreSQL
Problema: Tengo lógica compleja en funciones/stored procedures de PostgreSQL.
Solución: Usa IDataFunctions para invocarlas
public class AuthService
{
private readonly IDbSession _db;
private readonly IDataFunctions _funcs;
// Función que devuelve un escalar
public async Task<string?> GenerarTokenRecuperacionAsync(int usuarioId, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _funcs.CallFunctionAsync<string>(
_db,
"generar_token_recuperacion",
new { p_usuario_id = usuarioId, p_duracion_horas = 24 },
schema: "auth",
ct);
}
// Función que devuelve una tabla (SETOF o TABLE)
public async Task<List<ValidacionDto>> ValidarTokenAsync(string token, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _funcs.CallFunctionListAsync<ValidacionDto>(
_db,
"validar_token_recuperacion",
new { p_token = token },
schema: "auth",
ct);
}
// Función void (sin retorno)
public async Task IncrementarIntentoAsync(string token, CancellationToken ct)
{
await _db.OpenAsync(ct);
await _funcs.CallVoidFunctionAsync(
_db,
"incrementar_intento_token",
new { p_token = token },
schema: "auth",
ct);
}
}
Ejemplo de función PostgreSQL:
CREATE OR REPLACE FUNCTION auth.generar_token_recuperacion(
p_usuario_id int,
p_duracion_horas int DEFAULT 24
)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
v_token text;
BEGIN
v_token := encode(gen_random_bytes(32), 'hex');
INSERT INTO auth.tokens_recuperacion (usuario_id, token, expira_en)
VALUES (p_usuario_id, v_token, now() + (p_duracion_horas || ' hours')::interval);
RETURN v_token;
END;
$$;
Ver más: Referencia IDataFunctions
Caso 5: JSONB
Problema: Necesito almacenar/consultar datos en formato JSON.
Solución: Usa JObject de Newtonsoft.Json, Dictionary<,> o cualquier tipo complejo con el atributo [Jsonb].
MicroOrmGesg detecta automáticamente como JSONB:
JObject,JArray,JToken(siempre)Dictionary<TKey, TValue>(siempre, desde v1.2.0)- Cualquier propiedad marcada con
[Jsonb]
1. Registrar TypeHandlers (una vez en Program.cs):
using Dapper;
using MicroOrmGesg.Utils;
SqlMapper.AddTypeHandler(new JObjectTypeHandler());
SqlMapper.AddTypeHandler(new JArrayTypeHandler());
SqlMapper.AddTypeHandler(new JTokenTypeHandler());
2. Define tu entidad con JSONB:
using Newtonsoft.Json.Linq;
using MicroOrmGesg;
[Table("usuarios")]
public class Usuario
{
[Key]
public int Id { get; set; }
public string Nombre { get; set; } = null!;
[Jsonb]
public JObject? Preferencias { get; set; }
[Jsonb]
public JObject? Metadatos { get; set; }
// Dictionary se detecta como JSONB automáticamente (sin atributo)
public Dictionary<string, string> Tags { get; set; } = new();
}
3. Úsalo normalmente:
// Crear con JSONB
var usuario = new Usuario
{
Nombre = "Juan",
Preferencias = JObject.FromObject(new
{
tema = "dark",
idioma = "es",
notificaciones = true
}),
Metadatos = JObject.FromObject(new
{
ip_registro = "192.168.1.1",
navegador = "Chrome"
})
};
await _db.OpenAsync(ct);
var id = await _repo.InsertAsyncReturnId(_db, usuario, ct);
// Leer
var usuarioLeido = await _repo.GetByIdAsync(_db, (int)id!, ct);
var tema = usuarioLeido?.Preferencias?["tema"]?.ToString(); // "dark"
// Actualizar parcialmente el JSONB
var nuevoJson = JObject.FromObject(new { tema = "light", idioma = "en" });
await _repo.UpdateSetAsync(_db, (int)id!, new { preferencias = nuevoJson }, ct);
4. Consultas con operadores JSONB:
const string sql = @"
SELECT * FROM usuarios
WHERE preferencias->>'tema' = @tema
AND preferencias->'notificaciones' = 'true'::jsonb";
var usuarios = await _query.QueryAsync<Usuario>(
_db, sql, new { tema = "dark" }, ct);
Ver más: Extensiones JSON | Filtros JSONB | GetJsonPartAsync
Caso 6: Migraciones
Problema: Necesito gestionar cambios en el esquema de base de datos de forma controlada.
Solución: Usa el sistema de migraciones integrado
1. Configura migraciones en Program.cs:
using MicroOrmGesg.Migrations.Extensions;
using MicroOrmGesg.Migrations.Models;
builder.Services.AddPgMigrations(options =>
{
options.AdvisoryLockKey = "myapp:migrations";
options.DriftPolicy = DriftPolicy.WarnAndSkip;
options.StopOnError = true;
});
2. Crea tu archivo de migraciones (scripts/schema.sql):
-- @step id:001 name:create.usuarios
CREATE TABLE IF NOT EXISTS usuarios(
id serial PRIMARY KEY,
nombre text NOT NULL,
email text NOT NULL UNIQUE,
eliminado boolean NOT NULL DEFAULT false
);
-- @step id:002 name:index.usuarios.email
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email) WHERE eliminado = false;
-- @step id:003 name:create.pedidos
CREATE TABLE IF NOT EXISTS pedidos(
id serial PRIMARY KEY,
usuario_id int NOT NULL REFERENCES usuarios(id),
fecha timestamptz NOT NULL DEFAULT now(),
total decimal(10,2) NOT NULL
);
-- @step id:004 name:alter.usuarios.add_telefono
-- @check SELECT EXISTS(
-- SELECT 1 FROM information_schema.columns
-- WHERE table_name='usuarios' AND column_name='telefono'
-- );
ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS telefono text;
3. Ejecuta migraciones al inicio:
using MicroOrmGesg.Migrations;
var app = builder.Build();
// Ejecutar migraciones antes de iniciar
using (var scope = app.Services.CreateScope())
{
var migrator = scope.ServiceProvider.GetRequiredService<IPgMigrator>();
var source = new FileMigrationSource("./scripts/schema.sql");
var result = await migrator.RunAsync(source);
if (!result.IsSuccess)
{
Console.WriteLine($"Migraciones fallidas: {result.StepsFailed} pasos");
Environment.Exit(1);
}
}
app.Run();
Ventajas:
- Idempotentes (puedes ejecutarlas múltiples veces)
- Detección de drift (cambios no autorizados)
- Advisory locks (seguro en multi-instancia)
- Transacciones por paso
Ver más: Referencia completa de migraciones
🔧 Referencia completa
DbSession: Conexiones y transacciones
IDbSession gestiona una conexión y opcionalmente una transacción por scope (típicamente por request HTTP).
Métodos principales
public interface IDbSession
{
NpgsqlConnection? Connection { get; }
NpgsqlTransaction? Transaction { get; }
Task<NpgsqlConnection> OpenAsync(CancellationToken ct = default);
Task BeginTransactionAsync(IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default);
Task CommitAsync(CancellationToken ct = default);
Task RollbackAsync(CancellationToken ct = default);
Task ResetParaReintentoAsync(); // v1.2.2+
void Close();
}
Ciclo de vida típico
await _db.OpenAsync(ct); // 1. Abrir conexión
await _db.BeginTransactionAsync(ct: ct); // 2. Iniciar transacción (opcional)
try
{
// ... operaciones de base de datos ...
await _db.CommitAsync(ct); // 3. Confirmar
}
catch
{
await _db.RollbackAsync(ct); // 4. Revertir en caso de error
throw;
}
// 5. Dispose automático al finalizar el scope
Niveles de aislamiento
// Por defecto: ReadCommitted
await _db.BeginTransactionAsync(ct: ct);
// Serializable (más estricto)
await _db.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// RepeatableRead
await _db.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct);
ResetParaReintentoAsync (v1.2.2+)
Permite reutilizar una IDbSession tras un fallo transitorio de conexión, limpiando la transacción y conexión rotas sin marcar el objeto como disposed. Diseñado para integrarse con Polly u otros frameworks de resiliencia.
// Ejemplo con Polly v8
var pipeline = pipelineProvider.GetPipeline("db-retry");
await pipeline.ExecuteAsync(async token =>
{
await _db.ResetParaReintentoAsync(); // Limpia estado previo (no-op en primer intento)
await _db.OpenAsync(token);
await _db.BeginTransactionAsync(ct: token);
try
{
// ... operaciones de BD ...
await _db.CommitAsync(token);
}
catch
{
try { await _db.RollbackAsync(token); } catch { }
throw;
}
}, ct);
Logs generados
Con LogLevel.Debug:
[DBG] Abriendo nueva conexión a la base de datos desde el pool
[DBG] Iniciando transacción con nivel de aislamiento ReadCommitted
[DBG] Confirmando transacción (COMMIT)
IDataMicroOrm: Repositorio genérico
Repositorio tipado que proporciona CRUD completo sin escribir SQL.
Métodos disponibles
public interface IDataMicroOrm<T> where T : class
{
// Lectura
Task<T?> GetByIdAsync(IDbSession session, object id, CancellationToken ct = default);
Task<List<T>> GetAllAsync(IDbSession session, bool includeSoftDeleted = false, string? orderBy = null, ...);
Task<int> CountAsync(IDbSession session, bool includeSoftDeleted = false, ...);
Task<Page<T>> PageAsync(IDbSession session, int page, int size, ...);
// Escritura
Task<int> InsertAsync(IDbSession session, T data, CancellationToken ct = default);
Task<object?> InsertAsyncReturnId(IDbSession session, T data, CancellationToken ct = default);
Task<bool> UpdateAsync(IDbSession session, T data, CancellationToken ct = default);
Task<bool> UpdateSetAsync(IDbSession session, object id, object patch, CancellationToken ct = default);
Task<bool> DeleteAsync(IDbSession session, object id, CancellationToken ct = default);
}
Convenciones automáticas
| Convención | Ejemplo C# | SQL generado |
|---|---|---|
| Nombre de clase → tabla | Usuario |
usuarios (snake_case) |
| Nombre de propiedad → columna | NombreCompleto |
nombre_completo |
[Table("...")] |
[Table("users")] |
users (literal) |
[Column("...")] |
[Column("full_name")] |
full_name (literal) |
[Key] |
public int Id |
PRIMARY KEY autoincrement |
[SoftDelete] |
public bool Eliminado |
DELETE → UPDATE eliminado=true |
Paginación y filtros
var page = await _repo.PageAsync(
_db,
page: 2, // Página 2
size: 10, // 10 elementos por página
includeSoftDeleted: false, // Excluir eliminados
orderBy: "FechaCreacion", // Ordenar por propiedad C#
dir: SortDirection.Desc, // Descendente
filterField: "Nombre", // Filtrar por columna
filterValue: "Juan", // Valor a buscar
stringMode: StringFilterMode.Contains, // Coincidencia parcial
forceLowerCase: true, // Case-insensitive
ct: ct
);
Console.WriteLine($"Total: {page.Total}");
Console.WriteLine($"Página {page.PageNumber} de {Math.Ceiling(page.Total / (double)page.Size)}");
foreach (var item in page.Items)
{
// ...
}
UpdateSetAsync (PATCH parcial)
// Actualizar solo el email
await _repo.UpdateSetAsync(_db, usuarioId, new { email = "nuevo@example.com" }, ct);
// Actualizar múltiples campos
await _repo.UpdateSetAsync(_db, usuarioId, new
{
email = "nuevo@example.com",
nombre = "Nuevo Nombre",
telefono = "123456789"
}, ct);
// También acepta snake_case
await _repo.UpdateSetAsync(_db, usuarioId, new { password_hash = "xxx" }, ct);
Importante: UpdateSetAsync ignora automáticamente:
- La primary key (no se puede cambiar)
- Columnas de soft delete
- Columnas marcadas con
[Computed]o[Write(Include = false)]
InsertBulkAsync (inserción masiva)
Para insertar grandes volúmenes de datos, usa InsertBulkAsync que utiliza el protocolo binario COPY de PostgreSQL. Es órdenes de magnitud más rápido que múltiples llamadas a InsertAsync.
// Generar 10,000 productos de ejemplo
var productos = Enumerable.Range(1, 10000)
.Select(i => new Producto
{
Nombre = $"Producto {i}",
Precio = 9.99m + (i % 100),
Stock = i % 500
})
.ToList();
await _db.OpenAsync(ct);
await _db.BeginTransactionAsync(ct: ct); // Opcional pero recomendado
try
{
var insertados = await _repo.InsertBulkAsync(_db, productos, ct);
Console.WriteLine($"Insertados: {insertados} registros");
await _db.CommitAsync(ct);
}
catch
{
await _db.RollbackAsync(ct);
throw;
}
Características:
- Usa
COPY ... FROM STDIN (FORMAT BINARY)de PostgreSQL - Tipos .NET:
int,long,short,byte,decimal,double,float,string,bool,DateTime,DateTimeOffset,DateOnly,TimeOnly,TimeSpan,Guid,byte[], enums - Tipos NodaTime:
Instant,LocalDateTime,LocalDate,LocalTime,OffsetDateTime,ZonedDateTime,Period,Duration - JSONB: propiedades con
[Jsonb],JObject,JArray,JToken,Dictionary<,>(auto-detectado) - Enums PostgreSQL: propiedades con
[PgEnum]se envían como texto automáticamente - Arrays y listas:
int[],string[],Guid[],decimal[],double[],bool[],List<T>,IList<T> - Excluye automáticamente columnas
[Key],[Computed],[Write(Include=false)] - Retorna el número de registros insertados (
ulong)
Cuándo usar:
| Escenario | Método recomendado |
|-----------|-------------------|
| 1-10 registros | InsertAsync / InsertAsyncReturnId |
| 10-100 registros | Depende del caso (considera bulk) |
| 100+ registros | InsertBulkAsync |
| Importación masiva (CSV, logs) | InsertBulkAsync |
Nota: InsertBulkAsync NO retorna los IDs generados. Si necesitas los IDs, usa InsertAsyncReturnId en un bucle o considera una estrategia de IDs explícitos ([ExplicitKey] con GUIDs).
Extensiones JSON (JsonExtensions)
Utilidades para trabajar con datos JSONB de forma fluida y segura.
GetPath<T> - Acceso por ruta (JsonPath fluido)
Navega por JSON usando rutas separadas por puntos, con soporte para arrays:
using MicroOrmGesg.Utils;
var config = JObject.Parse(@"{
""meta"": {""version"": ""1.0""},
""views"": {""detail"": {""rows"": [{""slots"": [{""label"": ""Fecha""}]}]}}
}");
// Acceso simple
var version = config.GetPath<string>("meta.version"); // "1.0"
// Acceso a arrays con índices
var label = config.GetPath<string>("views.detail.rows[0].slots[0].label"); // "Fecha"
// Valor por defecto si no existe
var missing = config.GetPath<int>("missing.path", -1); // -1
// Verificar si existe la ruta
if (config.HasPath("meta.version")) { ... }
// Establecer valor (crea nodos intermedios)
config.SetPath("settings.theme", "dark");
GetTranslation - Soporte multi-idioma
Busca traducciones en arrays con formato [{"etiqueta": "Texto", "ididioma": 1}, ...]:
var labels = JArray.Parse(@"[
{""etiqueta"": ""Fecha"", ""ididioma"": 1},
{""etiqueta"": ""Date"", ""ididioma"": 2}
]");
// Obtener traducción por idioma
var spanish = labels.GetTranslation("etiqueta", languageId: 1); // "Fecha"
var english = labels.GetTranslation("etiqueta", languageId: 2); // "Date"
// Con fallback a otro idioma
var text = labels.GetTranslationWithFallback("etiqueta", 3, fallbackLanguageId: 1); // "Fecha"
// Obtener todas las traducciones
var all = labels.GetAllTranslations("etiqueta"); // {1: "Fecha", 2: "Date"}
// Buscar en ruta anidada
var title = config.GetTranslationAt("labels.title", "etiqueta", 1);
Filtros JSONB en consultas
Filtra por campos dentro de columnas JSONB usando operadores PostgreSQL:
// Filtrar por valor dentro de JSONB
// SQL: WHERE preferencias->'meta'->>'version' ILIKE '%1.0%'
var results = await _repo.GetAllAsync(
_db,
filterField: "preferencias->'meta'->>'version'",
filterValue: "1.0",
stringMode: StringFilterMode.Contains
);
// En PageAsync
var page = await _repo.PageAsync(
_db,
page: 1,
size: 10,
filterField: "config->>'status'",
filterValue: "active"
);
Nota: Cuando filterField contiene -> o ->>, se valida con regex estricto (previene inyección SQL) y se usa como expresión JSONB en el WHERE.
GetJsonPartAsync - Proyección parcial de JSONB
Recupera solo una parte de un campo JSONB grande:
// Obtener solo los filtros de una configuración grande
var filters = await _repo.GetJsonPartAsync<List<FilterDto>>(
_db,
configId,
jsonColumn: "configuracion",
path: "filters",
ct
);
// Obtener metadata anidada
var meta = await _repo.GetJsonPartAsync<MetaInfo>(
_db,
id,
jsonColumn: "datos",
path: "meta.info",
ct
);
// SQL generado: SELECT "configuracion"->>'filters' FROM tabla WHERE id = @id
// Para rutas anidadas: SELECT "datos"->'meta'->>'info' FROM tabla WHERE id = @id
Uso ideal: Cuando tienes configuraciones JSONB muy grandes y solo necesitas un fragmento específico.
IDirectQuery: Queries directas con Dapper
Ejecuta SQL personalizado compartiendo la misma conexión y transacción de IDbSession.
DirectQuery habilita automáticamente el mapeo snake_case → PascalCase de Dapper (DefaultTypeMap.MatchNamesWithUnderscores). Esto significa que puedes mapear directamente columnas PostgreSQL a DTOs en PascalCase sin alias ni configuración:
// PostgreSQL devuelve: fecha_creacion, nombre_completo, total_pedidos
// C# mapea automáticamente a: FechaCreacion, NombreCompleto, TotalPedidos
var resultados = await _query.QueryAsync<ReporteDto>(_db,
"SELECT fecha_creacion, nombre_completo, total_pedidos FROM vista_reportes", ct: ct);
Métodos disponibles
public interface IDirectQuery
{
Task<IEnumerable<T>> QueryAsync<T>(...);
Task<T> QuerySingleAsync<T>(...);
Task<T?> QuerySingleOrDefaultAsync<T>(...);
Task<T> QueryFirstAsync<T>(...);
Task<T?> QueryFirstOrDefaultAsync<T>(...);
Task<int> ExecuteAsync(...);
Task<T?> ExecuteScalarAsync<T>(...);
Task<SqlMapper.GridReader> QueryMultipleAsync(...);
}
Cuándo usar cada método
| Método | Uso | Ejemplo |
|---|---|---|
QueryAsync<T> |
Múltiples filas | SELECT * FROM usuarios |
QuerySingleAsync<T> |
Exactamente 1 fila (error si 0 o >1) | SELECT * FROM usuarios WHERE id = @id |
QuerySingleOrDefaultAsync<T> |
0 o 1 fila (error si >1) | Lo mismo, pero retorna null si no existe |
QueryFirstAsync<T> |
Al menos 1 fila (toma la primera) | SELECT * FROM usuarios LIMIT 1 |
QueryFirstOrDefaultAsync<T> |
0 o más filas (toma la primera o null) | Lo mismo, pero retorna null si vacío |
ExecuteAsync |
INSERT/UPDATE/DELETE | Retorna filas afectadas |
ExecuteScalarAsync<T> |
Un solo valor | SELECT COUNT(*) ... |
QueryMultipleAsync |
Múltiples result sets | Varios SELECT en una llamada |
Ejemplos rápidos
// SELECT múltiple
var usuarios = await _query.QueryAsync<Usuario>(
_db, "SELECT * FROM usuarios WHERE activo = @activo",
new { activo = true }, ct);
// INSERT con RETURNING
var nuevoId = await _query.ExecuteScalarAsync<int>(
_db, "INSERT INTO logs(mensaje) VALUES(@msg) RETURNING id",
new { msg = "Log entry" }, ct);
// UPDATE
var rowsAffected = await _query.ExecuteAsync(
_db, "UPDATE productos SET stock = stock - @qty WHERE id = @id",
new { qty = 5, id = 10 }, ct);
// COUNT
var total = await _query.ExecuteScalarAsync<int>(
_db, "SELECT COUNT(*) FROM pedidos WHERE fecha > @fecha",
new { fecha = DateTime.Today.AddDays(-30) }, ct);
Logs generados
[DBG] Ejecutando QueryAsync<Usuario>: SELECT * FROM usuarios WHERE activo = @activo
[DBG] QueryAsync<Usuario> ejecutado exitosamente
[DBG] Ejecutando ExecuteAsync (comando): UPDATE productos SET stock = ...
[DBG] ExecuteAsync completado: 1 fila(s) afectada(s)
IDataFunctions: Funciones PostgreSQL
Invoca funciones almacenadas en PostgreSQL sin escribir SQL manualmente.
Métodos
public interface IDataFunctions
{
// Función que devuelve un escalar
Task<TResult?> CallFunctionAsync<TResult>(
IDbSession session, string functionName, object? args = null,
string? schema = null, CancellationToken ct = default);
// Función que devuelve tabla (SETOF/TABLE)
Task<List<TResult>> CallFunctionListAsync<TResult>(
IDbSession session, string functionName, object? args = null,
string? schema = null, CancellationToken ct = default);
// Función void (sin retorno)
Task CallVoidFunctionAsync(
IDbSession session, string functionName, object? args = null,
string? schema = null, CancellationToken ct = default);
}
Parámetros
Puedes pasar argumentos de dos formas:
1. Objeto anónimo:
await _funcs.CallFunctionAsync<string>(
_db, "generar_token",
new { p_usuario_id = 123, p_duracion = 24 },
ct: ct);
2. Diccionario:
var args = new Dictionary<string, object?>
{
["p_usuario_id"] = 123,
["p_duracion"] = 24
};
await _funcs.CallFunctionAsync<string>(_db, "generar_token", args, ct: ct);
El prefijo @ se elimina automáticamente si lo incluyes.
SQL generado
// CallFunctionAsync (escalar)
await _funcs.CallFunctionAsync<int>(_db, "sumar", new { a = 5, b = 3 });
// SQL: SELECT sumar(@a, @b)
// CallFunctionListAsync (tabla)
await _funcs.CallFunctionListAsync<Usuario>(_db, "obtener_usuarios_activos");
// SQL: SELECT * FROM obtener_usuarios_activos()
// Con schema
await _funcs.CallFunctionAsync<string>(_db, "generar_hash", args, schema: "auth");
// SQL: SELECT auth.generar_hash(@password)
Sistema de migraciones
Sistema completo de migraciones idempotentes con checksums SHA-256, advisory locks y detección de drift.
Configuración
builder.Services.AddPgMigrations(options =>
{
options.AdvisoryLockKey = "myapp:migrations"; // Lock único por app
options.CommandTimeoutSeconds = 120; // Timeout de comandos
options.DriftPolicy = DriftPolicy.WarnAndSkip; // Fail, WarnAndSkip, Reapply
options.StopOnError = true; // Detener al primer error
options.JournalTableName = "__micro_orm_migrations"; // Tabla de historial
options.JournalSchema = null; // Schema (null = public)
});
Formato de script SQL
Directiva @step:
-- @step id:001 name:create.usuarios
id: Identificador único (001, 002, 003... o 001-create-users)name: Descripción (usa puntos: create.tabla, alter.tabla.columna)
Directiva @check (opcional):
-- @check SELECT to_regclass('public.usuarios') IS NOT NULL;
- SQL que devuelve boolean
true= ya aplicado,false= necesita aplicarse- Solo usar cuando no tienes
IF NOT EXISTS
Cuándo usar @check
| SQL | ¿Necesitas @check? | Razón |
|---|---|---|
CREATE TABLE IF NOT EXISTS |
❌ NO | Ya es idempotente |
CREATE INDEX IF NOT EXISTS |
❌ NO | Ya es idempotente |
CREATE OR REPLACE FUNCTION |
❌ NO | Ya es idempotente |
ALTER TABLE ADD COLUMN IF NOT EXISTS |
❌ NO | Ya es idempotente |
ALTER TABLE ALTER COLUMN TYPE |
✅ SÍ | No tiene IF NOT EXISTS |
ALTER TABLE ADD CONSTRAINT |
✅ SÍ | PostgreSQL < 16 no tiene IF NOT EXISTS |
INSERT INTO ... |
✅ SÍ | Para evitar duplicados |
| Migrar a BD existente | ✅ SÍ | Para adoptar objetos pre-existentes |
Ejemplo completo
-- Tabla con IF NOT EXISTS (sin @check)
-- @step id:001 name:create.usuarios
CREATE TABLE IF NOT EXISTS usuarios(
id serial PRIMARY KEY,
email text NOT NULL UNIQUE
);
-- Índice con IF NOT EXISTS (sin @check)
-- @step id:002 name:index.usuarios.email
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);
-- ALTER COLUMN sin IF NOT EXISTS (CON @check)
-- @step id:003 name:alter.usuarios.email_varchar
-- @check SELECT EXISTS(
-- SELECT 1 FROM information_schema.columns
-- WHERE table_name='usuarios' AND column_name='email'
-- AND data_type='character varying' AND character_maximum_length=255
-- );
ALTER TABLE usuarios ALTER COLUMN email TYPE varchar(255);
-- Función con CREATE OR REPLACE (sin @check)
-- @step id:004 name:function.update_timestamp
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Datos iniciales (CON @check)
-- @step id:005 name:insert.admin
-- @check SELECT EXISTS(SELECT 1 FROM usuarios WHERE email = 'admin@example.com');
INSERT INTO usuarios(email) VALUES('admin@example.com');
Ejecución
Opción 1: Manual en Program.cs
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var migrator = scope.ServiceProvider.GetRequiredService<IPgMigrator>();
var source = new FileMigrationSource("./scripts/schema.sql");
var result = await migrator.RunAsync(source);
if (!result.IsSuccess)
Environment.Exit(1);
}
app.Run();
Opción 2: IHostedService
builder.Services.AddHostedService<MigrationHostedService>();
Políticas de drift
Cuando un paso ya aplicado tiene un checksum diferente:
| Política | Comportamiento |
|---|---|
Fail |
Lanza excepción (fuerza corrección manual) |
WarnAndSkip |
Registra warning y omite (default) |
Reapply |
Re-ejecuta el paso (útil para funciones/vistas) |
Generar migraciones con LLM
Puedes usar este prompt con Claude/GPT para generar automáticamente las directivas:
Tengo las siguientes sentencias SQL de PostgreSQL y necesito generar
las directivas @step y @check para el sistema de migraciones de MicroOrmGesg.
REGLAS:
1. Si tiene IF NOT EXISTS, CREATE OR REPLACE, o ADD COLUMN IF NOT EXISTS → NO generar @check
2. Para CREATE TABLE sin IF NOT EXISTS → usar to_regclass('schema.tabla')
3. Para ALTER TABLE ALTER COLUMN TYPE → usar information_schema.columns
4. Para INSERT INTO → usar EXISTS(SELECT 1 FROM tabla WHERE condicion_unica)
FORMATO:
-- @step id:XXX name:descripcion.del.paso
-- @check SELECT ...; (solo si es necesario)
[SQL original]
SENTENCIAS SQL:
[Pega aquí tu SQL]
Row Level Security (multi-inquilino)
PostgreSQL puede filtrar filas por sí mismo con Row Level Security: defines una política sobre la tabla y el motor añade el predicado a todas las consultas, vengan de donde vengan. La ventaja frente a filtrar en C# es que olvidarse de un WHERE deja de ser una fuga de datos.
MicroOrmGesg fija por ti la variable de sesión que leen esas políticas. Es opcional: si no llamas a AddMicroOrmRls, no se aplica ningún contexto ni se ejecuta ninguna validación. (La única diferencia frente a v1.3.2 está en InsertBulkAsync, que consulta el catálogo una vez por tabla y rol para elegir estrategia; ver Inserción masiva más abajo.)
1. Preparar la base de datos
Dos roles distintos. Esto no es una recomendación de estilo: el propietario de una tabla ignora sus propias políticas, así que si la aplicación se conecta con el rol que creó las tablas, RLS no protege nada y todo parecerá funcionar.
-- Rol propietario: crea el esquema y ejecuta las migraciones
CREATE ROLE mi_app_owner LOGIN PASSWORD '...';
GRANT CREATE ON SCHEMA public TO mi_app_owner; -- en PostgreSQL 15+ no se concede por defecto
-- Rol de aplicación: ni superusuario, ni BYPASSRLS, ni propietario de nada
CREATE ROLE mi_app LOGIN PASSWORD '...' NOBYPASSRLS;
GRANT USAGE ON SCHEMA public TO mi_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mi_app;
La tabla, con la columna de inquilino rellenada por la propia base de datos:
CREATE TABLE articulo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entidad_id integer NOT NULL
DEFAULT nullif(current_setting('app.entidad_id', true), '')::integer,
titulo text NOT NULL
);
-- El predicado de RLS se añade a TODAS las consultas: sin índice, el plan se degrada
CREATE INDEX ON articulo (entidad_id);
ALTER TABLE articulo ENABLE ROW LEVEL SECURITY;
ALTER TABLE articulo FORCE ROW LEVEL SECURITY; -- sin esto, el propietario se la salta
-- Políticas SEPARADAS por comando, no una sola FOR ALL
CREATE POLICY articulo_sel ON articulo FOR SELECT
USING (entidad_id = nullif(current_setting('app.entidad_id', true), '')::integer);
CREATE POLICY articulo_ins ON articulo FOR INSERT
WITH CHECK (entidad_id = nullif(current_setting('app.entidad_id', true), '')::integer);
CREATE POLICY articulo_upd ON articulo FOR UPDATE
USING (entidad_id = nullif(current_setting('app.entidad_id', true), '')::integer)
WITH CHECK (entidad_id = nullif(current_setting('app.entidad_id', true), '')::integer);
CREATE POLICY articulo_del ON articulo FOR DELETE
USING (entidad_id = nullif(current_setting('app.entidad_id', true), '')::integer);
nullif(...)no es opcional. Basta con que la variable se haya fijado una vez en esa conexión para que deje de valerNULLy pase a valer cadena vacía: lo provoca elDISCARD ALLque Npgsql envía al devolver la conexión al pool, y también un simpleSET LOCALseguido deCOMMIT. Sin elnullif,''::integerlanza22P02en cada consulta de una conexión reciclada sin contexto: lo que debería ser «cero filas» se convierte en un error 500 que solo aparece en producción. La librería te avisa por log si detecta políticas sinnullif.
2. El modelo
Marca la columna de inquilino como [Computed]. Así queda fuera de WritableProps y, al no aparecer en ningún INSERT, la rellena el DEFAULT de la tabla — incluidos los caminos de inserción masiva.
[Table("articulo")]
public class Articulo
{
[Key] public long Id { get; set; }
[Computed] public int EntidadId { get; set; } // la pone la BD desde el contexto
public string Titulo { get; set; } = null!;
}
3. El proveedor de contexto
Lo implementas tú, porque solo tu aplicación sabe de dónde sale el inquilino (un claim del JWT, una cabecera, el subdominio…).
public sealed class ProveedorEntidad(IHttpContextAccessor acceso) : IPgRlsContextProvider
{
public PgRlsContext? Contexto
{
get
{
var ctx = acceso.HttpContext;
// Sin HttpContext (worker, tarea en segundo plano) o sin entidad resuelta
// (login, recuperación de contraseña, health, endpoint público): es legítimo.
if (ctx?.Items["EntidadId"] is not int entidadId)
return PgRlsContext.SinInquilino;
return PgRlsContext.Con("app.entidad_id", entidadId.ToString());
}
}
}
Los tres estados importan:
| Devuelves | Significa | Qué hace la librería |
|---|---|---|
PgRlsContext.Con(...) |
Hay inquilino | Aplica el contexto |
PgRlsContext.SinInquilino |
Petición legítima sin inquilino | No aplica nada, y limpia un contexto anterior de esa conexión |
null |
Error de programación | Lanza PgRlsContextoNoResueltoException |
Devolver SinInquilino en vez de null es lo normal en muchas peticiones. Si tu proveedor devuelve null para el login, acabarás desactivando la protección entera en la primera hora.
4. Registro
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IDbSession, DbSession>();
builder.Services.AddMicroOrmRls<ProveedorEntidad>(o =>
{
o.ClavesPermitidas.Clear();
o.ClavesPermitidas.Add("app.entidad_id");
});
Y ya está: cada operación que pase por IDbSession lleva el contexto, sin tocar los repositorios ni las consultas.
El orden importa, y
IDbSessiondebe registrarse por tipo. Llama aAddMicroOrmRlsdespués deAddScoped<IDbSession, DbSession>()y no vuelvas a registrarIDbSessionmás tarde. Si se registra con una fábrica (AddScoped<IDbSession>(sp => ...)), el contenedor construye la sesión con el constructor de dos parámetros y RLS queda desactivado sin avisar.AddMicroOrmRlsrechaza ese registro si ya existe cuando se le llama, pero no puede ver los que vengan después; para cubrir ese caso, resuelveIPgRlsSessionuna vez al arrancar (ver la guía de migración). Por el mismo motivo, un decorador registrado por fábrica sobreIDbSessiontampoco está soportado. Los registros con clave (AddKeyedScoped) se ignoran y no interfieren.
Opciones
| Opción | Por defecto | Para qué |
|---|---|---|
ClavesPermitidas |
app.tenant_id |
Lista blanca de GUC. set_config acepta cualquier variable, así que sin lista blanca una clave mal puesta (role, search_path) sería un SET ROLE parametrizado |
Estrategia |
Sesion |
Transaccion es obligatorio detrás de PgBouncer/Supavisor en pool_mode=transaction |
ValidarCadenaConexion |
true |
Rechaza Multiplexing y No Reset On Close |
ValidarRol |
true |
Activa la comprobación del rol descrita en la fila siguiente |
RolPrivilegiado |
Fallar |
Un rol superusuario o con BYPASSRLS ignora todas las políticas |
TablasRlsSinForce |
Avisar |
Tablas propias con RLS pero sin FORCE |
TablasSinForcePermitidas |
vacío | Silencia el aviso anterior tabla a tabla |
PoliticasSinNullif |
Avisar |
Detecta la trampa de la cadena vacía, aparición por aparición |
ClavesSinPolitica |
Avisar |
Cruza tu lista blanca con las políticas en ambos sentidos: claves que ninguna política lee (erratas) y variables que las políticas leen y la librería nunca fija |
PoliticasSinNullifPermitidas |
vacío | Silencia el aviso anterior de nullif para una política concreta (esquema.tabla.politica) |
ClavesSinPoliticaPermitidas |
vacío | Silencia el cruce para una clave concreta |
NivelLogSinInquilino |
Debug |
Nivel con el que se registra una petición sin inquilino |
Las dos comprobaciones de políticas solo ven lo que está escrito en la política. Leen
current_setting('clave')literal en elUSINGy elWITH CHECK; una función envolvente —USING (entidad_id = mi_tenant()), que es el patrón habitual por rendimiento— es opaca para ellas. Eso produce un aviso falso en el cruce de claves y un falso negativo en el denullif. Si usas ese patrón, añade la clave aClavesSinPoliticaPermitidasy revisa elnullifdentro de tu función. Solo se auditan las tablas sobre las que el rol tiene algún privilegio, así que las políticas de otras aplicaciones de la misma base no interfieren.
Workers y tareas en segundo plano
No hay HttpContext, así que el contexto se fija a mano con IPgRlsSession, que AddMicroOrmRls registra apuntando a la misma sesión del ámbito:
foreach (var entidadId in entidades)
{
// Orden importante: contexto ANTES del BEGIN
await rlsSession.EstablecerContextoAsync(
PgRlsContext.Con("app.entidad_id", entidadId.ToString()), ct);
await session.BeginTransactionAsync(ct: ct);
await ProcesarAsync(session, ct);
await session.CommitAsync(ct);
}
Cambiar el contexto con una transacción abierta lanza: cambiar de inquilino a mitad de una unidad de trabajo nunca es intencionado.
El contexto fijado a mano tiene prioridad sobre el proveedor hasta que se llame a
RestablecerProveedorAsync. Importa en una sesión que mezcle ambos mecanismos: sin esa llamada,
el proveedor deja de consultarse en esa sesión para siempre.
Inserción masiva
COPY FROM no funciona sobre tablas con RLS (documentación de COPY: "Currently, COPY FROM is not supported for tables with row-level security", error 0A000). InsertBulkAsync elige el camino automáticamente:
await repo.InsertBulkAsync(session, items, ct); // EstrategiaBulk.Auto
Auto consulta una sola vez por tabla si tiene RLS: si lo tiene, copia a una tabla temporal y hace INSERT … SELECT; si no, usa el COPY directo de siempre y conserva su rendimiento. Puedes forzarlo con EstrategiaBulk.CopyDirecto o CopyViaTemporal.
El camino temporal necesita privilegio TEMP sobre la base de datos: GRANT TEMP ON DATABASE mi_base TO mi_app;.
Trampas que conviene conocer
- Soft delete es un
UPDATE. Una entidad con[SoftDelete]necesita políticaFOR UPDATE, noFOR DELETE. UpsertBulkAsyncconON CONFLICT DO UPDATEcontra una fila de otro inquilino lanza42501y aborta el lote entero; PostgreSQL nunca omite la rutaUPDATEen silencio. ConDO NOTHINGla omite sin ruido.- Filas invisibles devuelven
falseenUpdateAsync/DeleteAsync, y con[ConcurrencyToken]lanzanDbConcurrencyException: es un 409 que en realidad es un 404 o un 403. - Funciones
SECURITY DEFINERejecutadas porIDataFunctionscorren como su propietario y se saltan las políticas. - Las vistas evalúan las políticas con el propietario de la vista salvo que se creen con
security_invoker = true. - Las migraciones (
IPgMigrator) deben correr con el rol propietario, en unNpgsqlDataSourcedistinto del de la aplicación.
Migrar un proyecto que ya hace RLS a mano
Si tu proyecto ya fija el contexto por su cuenta —repartiendo SET LOCAL por los servicios, por
ejemplo— la migración es gradual y los dos mecanismos conviven: dentro de una transacción, un
SET LOCAL gana sobre el valor de sesión y se revierte al terminar. Puedes migrar fichero a
fichero.
Pero hay un orden obligatorio, porque dos de los pasos previenen fallos que solo se manifiestan en producción.
1. Arregla primero las políticas con nullif. Antes de registrar nada.
-- Localiza las que te van a romper.
-- Ojo con el ILIKE: el catálogo guarda la expresión deparseada, y ahí NULLIF va en MAYÚSCULAS.
-- Con LIKE te devolvería también las políticas correctas.
SELECT schemaname, tablename, policyname
FROM pg_policies
WHERE (qual ILIKE '%current_setting%' OR with_check ILIKE '%current_setting%')
AND COALESCE(qual, '') || COALESCE(with_check, '') NOT ILIKE '%nullif(%';
Esta consulta es una primera criba y mira la política entera, así que no detecta una política con
dos variables de las que solo una lleve nullif. Para una comprobación exacta, arranca la
aplicación con PoliticasSinNullif = NivelAvisoRls.Fallar: la librería revisa cada aparición por
separado.
Por qué primero: hoy sobrevives porque casi todas tus peticiones fijan contexto, así que la
variable rara vez se lee vacía. En cuanto la librería la gestione, quedará definida en el 100 % de
las conexiones del pool, y cualquier flujo sin inquilino sobre una tabla con FORCE pasará de
devolver cero filas a lanzar 22P02. Es una regresión que introduces tú al migrar, no un fallo
de la librería, y no la verás hasta que se recicle la primera conexión en producción.
Pon PoliticasSinNullif = NivelAvisoRls.Fallar en desarrollo y en CI mientras dure la migración.
2. Comprueba cómo registras IDbSession. Debe ser AddScoped<IDbSession, DbSession>(), por
tipo, y AddMicroOrmRls debe ir después. Si se registra con una fábrica, es la lambda quien
construye la sesión, y lo hace con el constructor de dos parámetros: RLS queda activado sin hacer
nada, ni contexto, ni validaciones, ni aviso. Si tu aplicación conecta con un rol propietario, el
síntoma no será «cero filas» sino «se ve todo».
AddMicroOrmRls rechaza un registro por fábrica que ya exista cuando se le llama, pero no puede
ver los que lleguen después. Añade esta autocomprobación tras construir la aplicación y te cubre
los dos órdenes:
var app = builder.Build();
// Falla al arrancar si IDbSession quedó registrado de una forma que impide inyectar el contexto.
using (var scope = app.Services.CreateScope())
scope.ServiceProvider.GetRequiredService<IPgRlsSession>();
3. Inventaria tus flujos sin inquilino y haz que el proveedor devuelva SinInquilino, no
null. Los habituales: login, refresco de token, recuperación de contraseña, el middleware que
resuelve el inquilino (que abre sesión antes de saber cuál es), los servicios en segundo plano
y las pantallas de administración transversal. Si devuelves null en cualquiera de ellos,
acabarás desactivando la protección entera.
4. Declara las tablas de plataforma. Si tienes tablas con ENABLE pero deliberadamente sin
FORCE —colas de trabajo, registros de actividad, cachés compartidas— añádelas a
TablasSinForcePermitidas. El aviso seguirá listando las demás, que es justo lo que te interesa
ver.
5. Revisa la cadena de conexión de producción. Multiplexing o No Reset On Close hacen que
la primera OpenAsync lance PgRlsConfiguracionException, así que un despliegue con
cualquiera de los dos se rompe en el arranque. Ambos son incompatibles con RLS por el mismo motivo:
el contexto del inquilino dejaría de estar atado de forma fiable a la conexión lógica.
6. Confirma que no hay un pooler transaccional delante. PgBouncer, Odyssey o Supavisor en
pool_mode=transaction filtran el estado de sesión entre clientes, y la librería no puede
detectarlo: la cadena de conexión es idéntica. Si lo hay, EstrategiaContextoRls.Transaccion es
obligatorio.
7. Busca set_config(..., false) o SET de sesión en tu código. Un SET LOCAL convive sin
problema, pero fijar la variable a nivel de sesión por tu cuenta desincroniza la huella interna y
la librería creerá que el contexto sigue siendo el que ella aplicó.
8. Solo entonces, registra y migra. Deja ClavesSinPolitica en su valor por defecto: avisa si
alguna clave de tu lista blanca no la lee ninguna política, que es como se manifiesta una errata
del tipo app.entidad frente a app.entidad_id — y que sin este aviso aparece en producción como
un inexplicable «no sale nada».
Logging y diagnóstico
Todos los componentes incluyen logging con ILogger<T>.
Componentes con logging
| Componente | Qué registra |
|---|---|
DbSession |
Conexiones, transacciones, commits, rollbacks |
DirectQuery |
Queries ejecutadas, tipo de resultado, filas afectadas |
DataFunctionsRepository |
Funciones invocadas, esquema, resultados |
PgMigrator |
Ejecución completa de migraciones |
Niveles usados
| Nivel | Cuándo |
|---|---|
Debug |
Operaciones normales (conexión, query, commit) |
Information |
Eventos importantes (migraciones aplicadas) |
Warning |
Situaciones anómalas (rollback, drift) |
Error |
Excepciones y errores |
Configuración
// Desarrollo: Todo en Debug
if (builder.Environment.IsDevelopment())
{
builder.Logging.AddFilter("MicroOrmGesg", LogLevel.Debug);
}
else
{
// Producción: Solo Information y superiores
builder.Logging.AddFilter("MicroOrmGesg", LogLevel.Information);
}
// Filtro selectivo por componente
builder.Logging.AddFilter("MicroOrmGesg.Repository.DbSession", LogLevel.Debug);
builder.Logging.AddFilter("MicroOrmGesg.Migrations", LogLevel.Information);
Ejemplo de logs
[10:15:32 DBG] Abriendo nueva conexión a la base de datos desde el pool
[10:15:32 DBG] Iniciando transacción con nivel de aislamiento ReadCommitted
[10:15:32 DBG] Ejecutando QueryAsync<Usuario>: SELECT * FROM usuarios WHERE id = @id
[10:15:32 DBG] QueryAsync<Usuario> ejecutado exitosamente
[10:15:32 DBG] Confirmando transacción (COMMIT)
[10:15:32 ERR] Error ejecutando QueryAsync<Producto>: SELECT * FROM productos...
[10:17:21 WRN] Revirtiendo transacción (ROLLBACK)
Importante: Los logs incluyen SQL pero NO los valores de los parámetros (seguridad).
Atributos de mapeo
| Atributo | Uso | Ejemplo |
|---|---|---|
[Table("nombre")] |
Nombre de tabla | [Table("users")] |
[Column("nombre")] |
Nombre de columna | [Column("full_name")] |
[Key] |
Primary key autoincrement | [Key] public int Id |
[ExplicitKey] |
Primary key manual (no autoincrement) | [ExplicitKey] public string Code |
[SoftDelete] |
Columna de soft delete | [SoftDelete] public bool Eliminado |
[Computed] |
Solo lectura (no incluir en INSERT/UPDATE) | [Computed] public DateTime CreatedAt |
[Write(Include = false)] |
Excluir de escritura | [Write(Include = false)] |
[Jsonb] |
Columna JSONB | [Jsonb] public JObject Data |
[PgEnum] |
Enum PostgreSQL (envía como texto) | [PgEnum] public MiEnum Estado |
[ConcurrencyToken] |
Token de concurrencia optimista (xmin o columna incremental) | [ConcurrencyToken, Column("xmin"), Computed] public uint RowVersion |
Detección automática JSONB (v1.2.0): Las propiedades
Dictionary<,>,JObject,JArrayyJTokense detectan como JSONB automáticamente sin necesidad de[Jsonb]. El atributo sigue siendo útil para tipos personalizados que quieras serializar como JSON.
Ejemplo completo
// PostgreSQL: CREATE TYPE rol_enum AS ENUM ('Admin','Editor','Lector');
public enum Rol { Admin, Editor, Lector }
[Table("usuarios", Schema = "public")]
public class Usuario
{
[Key]
public int Id { get; set; }
public string Nombre { get; set; } = null!; // → nombre (snake_case automático)
[Column("email_address")]
public string Email { get; set; } = null!; // → email_address (literal)
[Column("password_hash")]
public string PasswordHash { get; set; } = null!;
[PgEnum]
public Rol Rol { get; set; } // Enum PG → se envía como texto "Admin"
[Computed]
public DateTime CreatedAt { get; set; } // Solo lectura
[SoftDelete]
public bool Eliminado { get; set; } // DELETE → UPDATE eliminado=true
[Jsonb]
public JObject? Preferencias { get; set; } // JSONB explícito
public Dictionary<string, object> Config { get; set; } = new(); // JSONB auto-detectado
[Write(Include = false)]
public string TempPassword { get; set; } = null!; // No se incluye en INSERT/UPDATE
}
Concurrencia optimista ([ConcurrencyToken])
Desde v1.3.0. Control de concurrencia optimista declarativo, 100% opt-in y retrocompatible: si una entidad no declara
[ConcurrencyToken], el comportamiento deUpdateAsync/UpdateSetAsynces idéntico al anterior.
Marca una propiedad con [ConcurrencyToken]. En cada UPDATE, la librería añade la columna del token al WHERE; si ninguna fila resulta afectada (otra operación modificó o borró la fila entre la lectura y la escritura), lanza DbConcurrencyException. La forma idiomática de tratarla en una API REST es devolver 409 Conflict.
El modo se autodetecta por el nombre de columna:
| Modo | Cuándo | Comportamiento |
|---|---|---|
xmin (recomendado) |
La columna mapeada es la columna de sistema xmin (vía [Column("xmin")]) |
No ocupa columna física ni se escribe; solo se compara en el WHERE (xmin::text = @token). Tipo recomendado: uint. |
| Incremental | Columna normal, p. ej. row_version bigint |
Se excluye de la escritura y se incrementa con col = col + 1 en cada UPDATE. La columna debe tener DEFAULT en la BD (no se incluye en el INSERT). |
Modo xmin (sin columna física, coste cero):
// PostgreSQL: la tabla NO necesita columna extra; xmin es una columna de sistema.
[Table("solicitud")]
public class Solicitud
{
[Key] public long Id { get; set; }
public string Estado { get; set; } = null!;
// Se lee en el SELECT; nunca se escribe; se compara en el WHERE del UPDATE.
[ConcurrencyToken, Column("xmin"), Computed]
public uint RowVersion { get; set; }
}
// Update completo: carga la entidad (con su RowVersion) y actualiza.
var sol = await _repo.GetByIdAsync(_db, id, ct); // trae RowVersion (xmin actual)
sol!.Estado = "remitida_servicio";
try
{
await _repo.UpdateAsync(_db, sol, ct); // WHERE id=@id AND xmin::text=@token
}
catch (DbConcurrencyException)
{
return Results.Conflict(); // 409: otro técnico ganó la carrera
}
// PATCH parcial: incluye el token en el patch para que se use en el WHERE.
await _repo.UpdateSetAsync(_db, id, new { estado = "resuelta", RowVersion = sol.RowVersion }, ct);
Modo incremental:
-- PostgreSQL: la columna debe tener DEFAULT
ALTER TABLE documento ADD COLUMN row_version bigint NOT NULL DEFAULT 1;
[ConcurrencyToken]
public long RowVersion { get; set; } // → row_version; la lib hace "row_version = row_version + 1"
Notas:
- En
UpdateAsync, si la entidad declara el token y su valor esnull/no cargado, se lanzaInvalidOperationException(debes cargarlo antes de actualizar). - En
UpdateSetAsync, el token debe venir en el patch; si falta, se lanzaInvalidOperationException. - El
xminse compara como texto (xmin::text = @token) para no depender del tipoxiden el parámetro.
Paginación y filtrado
PageAsync
public async Task<Page<Usuario>> ListarAsync(int page, int size, string? busqueda, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.PageAsync(
_db,
page: page, // Número de página (1-based)
size: size, // Elementos por página
includeSoftDeleted: false, // Excluir eliminados
orderBy: "FechaCreacion", // Columna para ordenar
dir: SortDirection.Desc, // Ascendente/Descendente
filterField: "Nombre", // Filtrar por campo
filterValue: busqueda, // Valor a buscar
stringMode: StringFilterMode.Contains, // Equals, Contains, StartsWith, EndsWith
forceLowerCase: true, // Case-insensitive
ct: ct
);
}
Resultado Page
public class Page<T>
{
public List<T> Items { get; init; } // Elementos de la página actual
public int Total { get; init; } // Total de registros
public int PageNumber { get; init; } // Página actual
public int Size { get; init; } // Tamaño de página
}
StringFilterMode
| Modo | SQL generado | Ejemplo |
|---|---|---|
Equals |
columna = @valor |
"Juan" |
Contains |
columna ILIKE '%' \|\| @valor \|\| '%' |
Buscar "uan" encuentra "Juan" |
StartsWith |
columna ILIKE @valor \|\| '%' |
"Ju" encuentra "Juan" |
EndsWith |
columna ILIKE '%' \|\| @valor |
"an" encuentra "Juan" |
SqlLiteralFormatter: literales SQL seguros
SqlLiteralFormatter.Format(object? valor) convierte cualquier valor CLR a un literal SQL seguro para PostgreSQL. Pensado para generar scripts SQL planos (exportaciones, dumps, migraciones) donde no se pueden usar parámetros Dapper.
Regla de oro: En queries normales usa SIEMPRE parámetros (
@param). Este helper solo para scripts donde el SQL tiene que ser texto final (se escribe a fichero, se reimporta, se envía a un cliente que no soporta parámetros, etc).
¿Por qué existe?
Cuando NpgsqlDataSourceBuilder.UseNodaTime() está habilitado y haces Dapper.QueryAsync sin tipo genérico (devuelve DapperRow/dynamic), los TypeHandlers de Dapper no se aplican — Npgsql entrega tipos NodaTime crudos (Instant, LocalDateTime, LocalDate, LocalTime).
Si formateas esos valores con .ToString() heredas la cultura del proceso. En un contenedor con locale es-ES una fecha se convierte en "13/4/2026 18:32:50" — formato que PostgreSQL rechaza al reimportar (22008: date/time field value out of range).
SqlLiteralFormatter resuelve esto serializando siempre en ISO 8601 con CultureInfo.InvariantCulture, independiente del locale del proceso.
Tipos soportados
| Categoría | Tipos | Formato producido |
|---|---|---|
| Nulos | null, DBNull.Value |
NULL |
| Booleanos | bool |
true / false |
| Enteros | int, long, short, byte, sbyte, uint, ulong, ushort |
literal numérico |
| Decimales | decimal, float, double |
literal con . como separador (InvariantCulture) |
| Enums | Enum |
valor numérico subyacente |
| Identidades | Guid |
'xxxxxxxx-xxxx-...' |
| Binario | byte[] |
'\xHEX' (bytea de PostgreSQL) |
| BCL fechas | DateTime, DateOnly, TimeOnly, TimeSpan, DateTimeOffset |
ISO 8601 |
| NodaTime | Instant, LocalDateTime, LocalDate, LocalTime, ZonedDateTime, OffsetDateTime, Duration |
ISO 8601 |
| JSON | JObject, JArray, JToken, IDictionary<string, object?> |
E'{...}'::jsonb |
| Texto | string y fallback |
E'texto escapado' (E-string con \\ y '' escapados) |
DateTime preserva la semántica de su Kind:
Utc→'2026-04-13T18:32:50.0000000Z'Local→'2026-04-13T18:32:50.0000000+01:00'Unspecified→'2026-04-13T18:32:50.0000000'
Ejemplo básico
using MicroOrmGesg.Utils;
SqlLiteralFormatter.Format(null); // NULL
SqlLiteralFormatter.Format(true); // true
SqlLiteralFormatter.Format(42); // 42
SqlLiteralFormatter.Format(3.14m); // 3.14
SqlLiteralFormatter.Format("it's"); // E'it''s'
SqlLiteralFormatter.Format(Guid.NewGuid()); // '6f9619ff-8b86-d011-b42d-00cf4fc964ff'
SqlLiteralFormatter.Format(new byte[] { 0x48, 0x69 }); // '\x4869'
// Fechas — siempre ISO 8601 independiente de la cultura del proceso
SqlLiteralFormatter.Format(new DateTime(2026, 4, 13, 18, 32, 50, DateTimeKind.Utc));
// → '2026-04-13T18:32:50.0000000Z'
SqlLiteralFormatter.Format(Instant.FromUtc(2026, 4, 13, 18, 32, 50));
// → '2026-04-13T18:32:50Z'
// JSONB con cast explícito
SqlLiteralFormatter.Format(new JObject { ["a"] = 1, ["b"] = "hola" });
// → E'{"a":1,"b":"hola"}'::jsonb
Uso típico: generar un script INSERT
using MicroOrmGesg.Utils;
var filas = await connection.QueryAsync("SELECT id, nombre, fecha_creacion, metadatos FROM tabla");
var sb = new StringBuilder();
sb.AppendLine("BEGIN;");
foreach (var fila in filas)
{
var dict = (IDictionary<string, object?>)fila;
var valores = dict.Values.Select(SqlLiteralFormatter.Format);
sb.AppendLine(
$"INSERT INTO tabla (id, nombre, fecha_creacion, metadatos) " +
$"VALUES ({string.Join(", ", valores)}) ON CONFLICT (id) DO NOTHING;");
}
sb.AppendLine("COMMIT;");
File.WriteAllText("export.sql", sb.ToString());
Escape manual de texto
Si solo necesitas escapar un string como E-string de PostgreSQL sin pasar por el switch completo:
SqlLiteralFormatter.EscaparTexto("O'Brien"); // E'O''Brien'
SqlLiteralFormatter.EscaparTexto("ruta\\path"); // E'ruta\\path'
SqlLiteralFormatter.EscaparTexto("sin especiales"); // 'sin especiales' (fast-path sin prefijo E)
Los null bytes (\0) se eliminan porque PostgreSQL no admite U+0000 dentro de strings.
Limitaciones
- No parametriza: si construyes SQL dinámico con input de usuario, parametriza con Dapper. Este helper asume que los valores vienen de fuentes de confianza (columnas de tu propia base de datos, configuración, etc.).
- Fechas sin zona horaria:
DateTimeconKind=Unspecifiedse serializa sin offset. Si la columna destino estimestamptz, PostgreSQL interpretará la fecha en la zona horaria del servidor. Si necesitas garantizar UTC, usaDateTime.SpecifyKind(dt, DateTimeKind.Utc)antes de llamar al helper, o pasa unInstantde NodaTime. - JSONB: se usa
Newtonsoft.Jsonpara serializar. Si tu modelo está tipado conSystem.Text.Json, conviértelo antes aJObjectoIDictionary<string, object?>.
📖 Recursos adicionales
Mejores prácticas
1. Siempre propaga CancellationToken
// ✅ BIEN
public async Task<Usuario?> ObtenerAsync(int id, CancellationToken ct)
{
await _db.OpenAsync(ct);
return await _repo.GetByIdAsync(_db, id, ct);
}
// ❌ MAL
public async Task<Usuario?> ObtenerAsync(int id)
{
await _db.OpenAsync(); // Sin ct
return await _repo.GetByIdAsync(_db, id); // Sin ct
}
2. Usa transacciones para operaciones múltiples
// ✅ BIEN - Atómico (todo o nada)
await _db.BeginTransactionAsync(ct: ct);
try
{
await _repo.InsertAsync(_db, pedido, ct);
await _query.ExecuteAsync(_db, "UPDATE stock...", ct);
await _db.CommitAsync(ct);
}
catch
{
await _db.RollbackAsync(ct);
throw;
}
// ❌ MAL - No atómico, puede quedar inconsistente
await _repo.InsertAsync(_db, pedido, ct);
await _query.ExecuteAsync(_db, "UPDATE stock...", ct);
3. Registra TypeHandlers de JSONB una sola vez
// En Program.cs, ANTES de builder.Build()
SqlMapper.AddTypeHandler(new JObjectTypeHandler());
SqlMapper.AddTypeHandler(new JArrayTypeHandler());
SqlMapper.AddTypeHandler(new JTokenTypeHandler());
4. Usa logging apropiado por entorno
if (builder.Environment.IsDevelopment())
{
builder.Logging.AddFilter("MicroOrmGesg", LogLevel.Debug); // Verbose
}
else
{
builder.Logging.AddFilter("MicroOrmGesg", LogLevel.Warning); // Solo advertencias
}
5. Configura timeout adecuado
// Para migraciones pesadas
services.AddPgMigrations(options =>
{
options.CommandTimeoutSeconds = 300; // 5 minutos
});
6. Nunca modifiques migraciones ya aplicadas
-- ❌ MAL: Modificar paso existente
-- @step id:001 name:create.usuarios
CREATE TABLE usuarios(
id serial PRIMARY KEY,
email text,
telefono text -- ← Agregado después
);
-- ✅ BIEN: Crear nuevo paso
-- @step id:002 name:alter.usuarios.add_telefono
ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS telefono text;
Troubleshooting
"La sesión está cerrada. Llama a OpenAsync() primero"
Causa: Intentaste usar IDbSession sin llamar a OpenAsync().
Solución:
await _db.OpenAsync(ct); // ← Agregar esta línea
await _repo.GetByIdAsync(_db, id, ct);
"Ya existe una transacción activa"
Causa: Llamaste a BeginTransactionAsync() dos veces sin hacer commit/rollback.
Solución:
// Asegúrate de commit o rollback antes de iniciar otra
await _db.CommitAsync(ct);
// Ahora puedes iniciar otra transacción
await _db.BeginTransactionAsync(ct: ct);
"El modelo X no tiene definida una clave primaria"
Causa: Tu entidad no tiene [Key] o [ExplicitKey].
Solución:
public class Usuario
{
[Key] // ← Agregar esto
public int Id { get; set; }
// ...
}
"No hay columnas válidas para actualizar con el patch proporcionado"
Causa: En UpdateSetAsync, todos los campos eran PK, soft delete o no existen.
Solución:
// Asegúrate de usar nombres correctos (C#, snake_case o [Column])
await _repo.UpdateSetAsync(_db, id, new { Nombre = "Juan" }, ct); // C#
await _repo.UpdateSetAsync(_db, id, new { nombre = "Juan" }, ct); // snake_case
Tip (v1.2.5+): Habilita
System.Diagnostics.Tracepara ver qué campos ignoraUpdateSetAsyncy por qué (campo no reconocido, clave primaria, soft delete).
"Drift detectado en paso XXX"
Causa: El SQL de un paso ya aplicado cambió (diferente checksum).
Solución:
- Revertir el cambio en el SQL (si fue un error)
- Crear un nuevo paso con el cambio deseado
- Cambiar política a
DriftPolicy.Reapplysi es seguro (funciones/vistas)
"Migration file not found"
Causa: La ruta al archivo SQL es incorrecta o el archivo no se copia al output.
Solución:
<!-- En tu .csproj -->
<ItemGroup>
<None Update="scripts\schema.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
Queries JSONB no funcionan
Causa: No registraste los TypeHandlers de JSONB.
Solución:
// En Program.cs
using Dapper;
using MicroOrmGesg.Utils;
SqlMapper.AddTypeHandler(new JObjectTypeHandler());
SqlMapper.AddTypeHandler(new JArrayTypeHandler());
SqlMapper.AddTypeHandler(new JTokenTypeHandler());
Compatibilidad DateTime / TimeSpan con Npgsql 6.0+ y NodaTime
A partir de Npgsql 6.0, los tipos que devuelve PostgreSQL cambiaron. Si tus modelos C# usan DateTime o TimeSpan, necesitas habilitar compatibilidad. Hay dos escenarios mutuamente excluyentes — elige solo uno:
| Tu configuración | Error típico | Método a llamar |
|---|---|---|
Npgsql 6.0+ sin UseNodaTime() |
Error parsing column X (DateOnly) |
NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility() |
Npgsql 6.0+ con UseNodaTime() |
Error parsing column X (Instant) |
NodaTimeCompatibility.EnableDateTimeCompatibility() |
Escenario 1 — Sin NodaTime (solo el cambio DateOnly/TimeOnly de Npgsql 6.0+):
// En Program.cs, ANTES de cualquier operación de base de datos
using MicroOrmGesg.Utils;
NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility();
Convierte automáticamente al leer:
DateOnly→DateTime(columnasdate)TimeOnly→TimeSpan(columnastime)
La escritura pasa DateTime/TimeSpan tal cual a Npgsql, que se encarga de la conversión al tipo de columna destino.
Parámetros
DateOnly/TimeOnlyenIDirectQuery(v1.2.12+): Si pasas unDateOnlyoTimeOnly(incluyendo nullables) como parámetro de Dapper en SQL crudo, los handlers fuerzanNpgsqlDbType.Date/NpgsqlDbType.Timeautomáticamente. Antes, la API genéricaIDataMicroOrm<T>ya lo hacía (porque construye losNpgsqlParameterella misma), pero las queries directas pasaban por Dapper sin tipo concreto y Npgsql fallaba la inferencia. Ahora ambas rutas se comportan igual. Aplica también aNodaTimeCompatibility.Lectura
DateOnly/TimeOnlyconUseNodaTime()(v1.2.13+): CuandoUseNodaTime()está activo y un modelo tiene propiedadesDateOnly/TimeOnly(o nullables) leídas desde columnasdate/time, Npgsql devuelveLocalDate/LocalTimeen vez del tipo nativo. ElParsede los handlers ahora cubreLocalDate → DateOnly,LocalDateTime → DateOnly(truncando la hora) yLocalTime → TimeOnly, evitando elInvalidCastExceptionque se producía en v1.2.12.
Escenario 2 — Con UseNodaTime() (Npgsql devuelve tipos NodaTime):
// En Program.cs, DESPUÉS de configurar UseNodaTime()
using MicroOrmGesg.Utils;
NodaTimeCompatibility.EnableDateTimeCompatibility();
Convierte automáticamente:
| Dirección | Conversión | Columna PostgreSQL |
|---|---|---|
| Lectura | Instant → DateTime |
timestamptz |
| Lectura | LocalDateTime → DateTime |
timestamp |
| Lectura | LocalDate → DateTime |
date |
| Lectura | LocalTime → TimeSpan |
time |
| Escritura | DateTime → Instant |
timestamptz |
| Escritura | TimeSpan → LocalTime |
time |
Limitación de escritura: El handler de
DateTimeconvierte siempre aInstant(asumetimestamptz). Para columnasdate, usaDateOnlyoLocalDateen tu modelo en lugar deDateTime.
NO llames a ambos métodos. Ambos registran TypeHandlers de Dapper para DateTime y TimeSpan — el segundo sobreescribe al primero.
Ambas llamadas son thread-safe e idempotentes. Comprueba el estado con .IsEnabled.
Alternativa (recomendada para proyectos nuevos): Usa directamente los tipos nativos en tus modelos: DateOnly, TimeOnly, Instant, LocalDate, etc.
Enums de PostgreSQL rechazan valores enteros
Causa: Por defecto, C# envía enums como integer, pero PostgreSQL espera el nombre de texto del tipo ENUM personalizado.
Solución: Marca la propiedad con [PgEnum]:
// PostgreSQL: CREATE TYPE estado_ticket_enum AS ENUM ('Abierto','Cerrado','Pendiente');
public enum EstadoTicket { Abierto, Cerrado, Pendiente }
[Table("tickets")]
public class Ticket
{
[Key]
public int Id { get; set; }
[PgEnum]
public EstadoTicket Estado { get; set; } // Se envía como "Abierto", no como 0
}
[PgEnum] envía el valor .ToString() del enum con NpgsqlDbType.Unknown, permitiendo que PostgreSQL aplique el cast implícito text → enum. Funciona en todos los métodos de escritura: InsertAsync, UpdateAsync, UpdateSetAsync e InsertBulkAsync.
Importante: Los valores del enum C# deben coincidir exactamente (case-sensitive) con los valores definidos en el tipo ENUM de PostgreSQL.
Dictionary<string,T> no se guarda como JSONB
Causa: En versiones anteriores solo se detectaban como JSONB las propiedades con [Jsonb], JToken/JObject/JArray.
Solución (v1.2.0): Dictionary<,> se detecta automáticamente como JSONB sin necesidad de atributos:
[Table("configuraciones")]
public class Config
{
[Key]
public int Id { get; set; }
// Se detecta automáticamente como JSONB
public Dictionary<string, string> Parametros { get; set; } = new();
public Dictionary<int, List<string>> Agrupaciones { get; set; } = new();
}
Si prefieres ser explícito, puedes seguir usando [Jsonb].
Historial de cambios
El detalle completo, con el porqué de cada cambio, está en CHANGELOG.md.
v1.4.0: Row Level Security opcional (documentación)
- ✅ Nueva funcionalidad:
AddMicroOrmRls+IPgRlsContextProviderfijan el contexto de inquilino enDbSession.OpenAsync, antes de cualquierBEGIN, sin cambiar ninguna firma deIDataMicroOrm,IDirectQueryniIDataFunctions. Sin registrar RLS el comportamiento es idéntico al de v1.3.2. - ✅ Nueva funcionalidad:
IPgRlsSessionpara workers y tareas en segundo plano, donde no hayHttpContext. - ✅ Nueva funcionalidad: validación al arrancar de la cadena de conexión (
Multiplexing,No Reset On Close), del rol (superusuario oBYPASSRLS), de las tablas propias con RLS sinFORCEy de las políticas escritas sinnullif. - ✅ Nueva funcionalidad:
EstrategiaBulkenInsertBulkAsync, porque PostgreSQL no admiteCOPY FROMsobre tablas con RLS (0A000).Autodetecta la tabla y solo entonces usa el camino de tabla temporal. - 🐛 Bugfix: al descartar una conexión rota se conservaba la transacción de la conexión anterior, y el siguiente
BeginTransactionAsyncfallaba con "Ya existe una transacción activa". - 🐛 Bugfix: el
catchdel ping deOpenAsyncera indiscriminado y podía destruir una conexión sana en mitad de unGridReadero de un import binario. - 🐛 Bugfix:
EntityMap.Forleía su caché fuera dellock; ahora usaConcurrentDictionary. - ✅ Mejora: la librería pasa a tener suite de tests (105, con Testcontainers y un fixture de dos roles).
- ✅ Nueva funcionalidad:
v1.2.13: Lectura
DateOnly/TimeOnlydesde tipos NodaTime- 🐛 Bugfix: Los handlers añadidos en v1.2.12 (
DateOnlyParameterHandler,NullableDateOnlyParameterHandler,TimeOnlyParameterHandler,NullableTimeOnlyParameterHandler) cubrían la escritura, pero suParseno contemplaba que conUseNodaTime()activo PostgreSQL devuelveLocalDate/LocalTimeen columnasdate/time. Si un modelo tenía propiedadesDateOnly/TimeOnlyleídas en ese escenario, lanzabaInvalidCastException. - ✅ Fix:
Parseahora reconoceLocalDate → DateOnly,LocalDateTime → DateOnly(truncando la hora) yLocalTime → TimeOnly, alineando el comportamiento conInstantToDateTimeHandlerdeNodaTimeCompatibility.
- 🐛 Bugfix: Los handlers añadidos en v1.2.12 (
v1.2.12: Handlers de parámetros
DateOnly/TimeOnlyparaIDirectQuery- 🐛 Bugfix: Pasar
DateOnly/TimeOnly(o sus nullable) como parámetro a Dapper en queries crudas (IDirectQuery) fallaba la inferencia de tipo de Npgsql. La API genéricaIDataMicroOrm<T>no estaba afectada porque construye losNpgsqlParameterdirectamente conNpgsqlDbType.Date/NpgsqlDbType.Time. - ✅ Fix: Nuevos
SqlMapper.TypeHandler<DateOnly>,TypeHandler<DateOnly?>,TypeHandler<TimeOnly>,TypeHandler<TimeOnly?>que enSetValuefuerzanNpgsqlDbType.Date/NpgsqlDbType.Time(con fallback aDbType.Date/DbType.Timesi el parámetro no esNpgsqlParameter). Los nullable mantienen el tipo aun cuando el valor esnull, para que PostgreSQL no falle por tipo desconocido enWHERE col = @param. - ✅ Mejora: Registro automático tanto en
NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility()como enNodaTimeCompatibility.EnableDateTimeCompatibility(). Los consumidores no necesitan cambios — la siguiente vez que arranquen quedan cubiertos.
- 🐛 Bugfix: Pasar
v1.2.11:
UpsertBulkAsynccon claves de conflicto compuestas- ✅ Mejora:
UpsertBulkAsyncadmite múltiples columnas como clave de conflicto (ON CONFLICT (col1, col2) DO UPDATE)
- ✅ Mejora:
v1.2.8:
UpsertBulkAsync— upserts masivos con COPY + ON CONFLICT- ✅ Nueva funcionalidad:
UpsertBulkAsynccombina la performance de Binary Import con la semántica deINSERT ... ON CONFLICT DO UPDATE. Internamente carga los datos a una tabla temporal con COPY y luego ejecuta el upsert.
- ✅ Nueva funcionalidad:
v1.2.9: Nuevo
SqlLiteralFormatterpara generar literales SQL sin depender de cultura- ✅ Nueva funcionalidad:
MicroOrmGesg.Utils.SqlLiteralFormatter.Format(object?)convierte cualquier valor CLR a literal SQL seguro para PostgreSQL, siempre conCultureInfo.InvariantCulturey formato ISO 8601- Soporte completo BCL:
DateTime(con semántica deKind),DateOnly,TimeOnly,TimeSpan,DateTimeOffset,Guid,byte[](bytea hex), enteros, decimales, enums - Soporte completo NodaTime:
Instant,LocalDateTime,LocalDate,LocalTime,ZonedDateTime,OffsetDateTime,Duration - JSONB:
JObject,JArray,JToken,IDictionary<string, object?>con cast explícito...::jsonb - Strings con E-strings de PostgreSQL (
\\y''escapados; null bytes eliminados)
- Soporte completo BCL:
- ✅ Nueva utilidad pública:
SqlLiteralFormatter.EscaparTexto(string)para escape manual como E-string - 🐛 Motivación (bugfix aguas arriba): Cuando
UseNodaTime()está habilitado y se haceQueryAsyncsin tipo genérico (devuelveDapperRow), Dapper no aplicaTypeHandlers y llegan tipos NodaTime crudos. Formatearlos con.ToString()heredaba la cultura del proceso (p. ej.es-ESen contenedores) produciendo fechas"13/4/2026 18:32:50"que PostgreSQL rechaza con22008: date/time field value out of range. Este helper centraliza la serialización y elimina la clase entera de bug en cualquier consumidor - 📖 Ver sección SqlLiteralFormatter: literales SQL seguros
- ✅ Nueva funcionalidad:
v1.2.5: Consistencia en validaciones y trazas de diagnóstico
- 🐛 Bugfix:
GetByIdAsyncno verificabasession.Connectionantes de usarla (todos los demás métodos CRUD sí lo hacían). Ahora lanzaInvalidOperationExceptioncon mensaje descriptivo si la sesión está cerrada. - ✅ Mejora:
UpdateSetAsyncemite trazas (Trace.TraceInformation) cuando ignora campos del patch, indicando el motivo: campo no reconocido, clave primaria, o columna de soft delete. Facilita diagnosticar por qué un patch "no hace nada". - ✅ Mejora:
GetJsonPartAsyncregistraTrace.TraceWarningcuando falla la deserialización del fragmento JSON, incluyendo columna, ruta y mensaje de error. Antes devolvíanullsilenciosamente.
- 🐛 Bugfix:
v1.2.4: Separación clara de handlers DateTime/NodaTime y correcciones internas
- ✅ Mejora:
NodaTimeCompatibilityahora maneja tambiénTimeSpan↔LocalTime(columnastime) - ✅ Mejora:
NpgsqlDateTimeCompatibility.SetValueya no fuerzaDateOnly— pasaDateTimetal cual para no romper columnastimestamptz - 🐛 Bugfix:
EntityMap— la columna de soft delete no se excluía deWritableProps(error de orden en constructor) - 🐛 Bugfix: Regex en
IsJsonbExpressionyBuildJsonPathExpressionse recompilaba en cada llamada (ahora esstatic readonly) - 🐛 Bugfix:
[PgEnum]usabaNpgsqlDbType.TextenInsertBulkAsyncvsNpgsqlDbType.Unknownen INSERT/UPDATE (unificado aUnknown) - ✅ Mejora:
ResetParaReintentoAsyncahora loguea excepciones conLogWarningen vez de tragarlas silenciosamente
- ✅ Mejora:
v1.2.2: Soporte para reintentos con Polly en escenarios de resiliencia
- ✅ Nueva funcionalidad:
IDbSession.ResetParaReintentoAsync()para limpiar el estado interno de la sesión tras un fallo transitorio- Libera la transacción y la conexión actuales (si existen) sin marcar el objeto como disposed
- Permite volver a llamar a
OpenAsync()y continuar operando en el siguiente intento - Diseñado para integrarse con políticas de reintentos (Polly, Microsoft.Extensions.Resilience)
- Caso de uso: Cuando una conexión TCP muere a mitad de una transacción,
RollbackAsyncpuede fallar y dejar_tx != null. Sin este método,BeginTransactionAsynclanzaríaInvalidOperationException("Ya existe una transacción activa")en el reintento
- ✅ Nueva funcionalidad:
v1.2.1: Corrección de doble serialización JSONB y compatibilidad con columnas
date- 🐛 Bugfix crítico: Propiedades
stringcon[Jsonb]se serializaban dos veces en INSERT/UPDATEBuildWriteParametersllamabaJsonConvert.SerializeObject(value)incluso cuando el valor ya era un string JSON- Esto producía un literal JSON string (
"{ ... }") en vez de un objeto JSON ({ ... }) - Afectaba a
InsertAsync,InsertAsyncReturnId,UpdateAsync,UpdateSetAsynceInsertBulkAsync - Síntoma: triggers de validación JSONB que usan el operador
?no encontraban las claves esperadas porque el valor era un string JSONB, no un objeto - Fix: Si el valor ya es
string, se usa directamente sin re-serializar
- 🐛 Bugfix:
NodaTimeCompatibilityno manejaba columnasdatenitimestamp(sin zona horaria)- Con
UseNodaTime(), PostgreSQLdatedevuelveLocalDateytimestampdevuelveLocalDateTime, pero los handlers solo manejabanInstant(paratimestamptz) - Modelos con
DateTime/DateTime?para columnasdatelanzabanInvalidCastExceptional leer - Fix: Añadidos
LocalDate,LocalDateTimeyDateOnlyaInstantToDateTimeHandleryNullableInstantToDateTimeHandler
- Con
- 🐛 Bugfix crítico: Propiedades
v1.2.0: Compatibilidad NodaTime, enums PostgreSQL, Dictionary JSONB y snake_case automático
- ✅ Nueva funcionalidad:
NodaTimeCompatibility.EnableDateTimeCompatibility()para modelos conDateTimecuandoUseNodaTime()está habilitadoInstantToDateTimeHandler: convierteInstant/LocalDate/LocalDateTime/DateOnly→DateTime(lectura) yDateTime→Instant(escritura)NullableInstantToDateTimeHandler: mismo comportamiento paraDateTime?- Cubre columnas
timestamptz,timestamp,dateyDateOnly(Npgsql 6.0+) - Thread-safe e idempotente (patrón double-check locking)
- ✅ Nuevo atributo:
[PgEnum]para propiedades enum que mapean a tipos ENUM personalizados de PostgreSQL- Envía el valor como texto (
NpgsqlDbType.Unknown) en INSERT/UPDATE/BulkInsert - Compatible con cast implícito
text → enumde PostgreSQL - Soportado en
BuildWriteParameters,UpdateSetAsyncyGetNpgsqlTypeAndValue
- Envía el valor como texto (
- ✅ Mejora: Auto-detección de
Dictionary<,>como JSONB- Propiedades
Dictionary<string, T>,Dictionary<int, T>, etc. se serializan automáticamente como JSONB - No requiere
[Jsonb]explícito (aunque sigue siendo compatible) - Soportado en INSERT, UPDATE, UpdateSet y BulkInsert
- Propiedades
- ✅ Mejora:
DirectQueryhabilitaMatchNamesWithUnderscoresautomáticamente- Mapea columnas
snake_case→ propiedadesPascalCasesin configuración adicional - Se activa via constructor estático (una sola vez al cargar la clase)
- Mapea columnas
- ✅ Nueva funcionalidad:
v1.1.2: Mejoras en manejo de conexiones y depuración
- Optimización en construcción de cadenas de conexión
- Trazas detalladas en
JsonExtensionspara facilitar depuración
v1.1.1: Utilidades JSONB y TypeHandlers
- ✅ Nueva funcionalidad:
JsonValueConverterpara manejo de valores JSONB - Mejoras en los TypeHandlers de Dapper para mayor compatibilidad con PostgreSQL
- ✅ Nueva funcionalidad:
v1.1.0: Actualización a .NET 10, Npgsql 10.0.1, JSONB y seguridad
- ⚠️ Breaking changes de Npgsql 10.0:
- PostgreSQL
dateahora mapea aDateOnly(antesDateTime) - PostgreSQL
timeahora mapea aTimeOnly(antesTimeSpan) - PostgreSQL
intervalcon meses/años no puede leerse comoTimeSpan— usar NodaTimePeriod cidrahora mapea aIPNetwork(antesNpgsqlCidr)
- PostgreSQL
- ✅ Nueva funcionalidad:
NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility()para modelos existentes conDateTime/TimeSpan- Valida rango TimeSpan 0-24h para columnas
timecon error descriptivo
- Valida rango TimeSpan 0-24h para columnas
- ✅ Nueva funcionalidad:
InsertBulkAsync()— inserción masiva con Binary Import (COPY protocol)- Soporta tipos NodaTime,
List<T>,IList<T>, arrays adicionales - Validación de entidades sin propiedades escribibles
- Soporta tipos NodaTime,
- ✅ Nueva funcionalidad:
JsonExtensions— utilidades para trabajar con JSONBGetPath<T>(),GetTranslation(),GetTranslationWithFallback(),GetAllTranslations()SetPath(),HasPath(),Flatten(),Merge()
- ✅ Nueva funcionalidad: Filtros JSONB en
GetAllAsync/PageAsync/CountAsync- Soporte para expresiones con operadores
->y->> - Validación de seguridad para prevenir SQL injection en expresiones JSONB
- Soporte para expresiones con operadores
- ✅ Nueva funcionalidad:
GetJsonPartAsync<T>()— proyección parcial de campos JSONB - 🔒 Seguridad: Validación robusta en
IsJsonbExpression()con regex estricto - 🐛 Bugfix:
FlattenRecursivemaneja correctamenteJNull,JRaw,JUndefined
- ⚠️ Breaking changes de Npgsql 10.0:
v1.0.2: IDirectQuery para queries directas con Dapper compartiendo IDbSession
v1.0.1: Sistema de migraciones completo con checksums SHA-256, advisory locks, detección de drift
v1.0.0: Versión inicial con repositorio genérico, DbSession, funciones PostgreSQL, JSONB
Licencia
MIT - ver LICENSE para más detalles.
Soporte
Para reportar issues o solicitar features: github.com/gesgocom/MicroOrmGesg
Showing the top 20 packages that depend on Gesgocom.MicroOrmGesg.
| Packages | Downloads |
|---|---|
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
332 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
6 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
7 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
15 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
50 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
22 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
31 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
9 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
47 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
3 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
8 |
|
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
|
38 |
.NET 10.0
- Dapper (>= 2.1.86)
- Newtonsoft.Json (>= 13.0.4)
- Npgsql (>= 10.0.3)
- Npgsql.NodaTime (>= 10.0.3)