Gesgocom.MicroOrmGesg 1.2.15

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

🔧 Referencia completa

📖 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_casePascalCase 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]

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

Detección automática JSONB (v1.2.0): Las propiedades Dictionary<,>, JObject, JArray y JToken se 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
}

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: DateTime con Kind=Unspecified se serializa sin offset. Si la columna destino es timestamptz, PostgreSQL interpretará la fecha en la zona horaria del servidor. Si necesitas garantizar UTC, usa DateTime.SpecifyKind(dt, DateTimeKind.Utc) antes de llamar al helper, o pasa un Instant de NodaTime.
  • JSONB: se usa Newtonsoft.Json para serializar. Si tu modelo está tipado con System.Text.Json, conviértelo antes a JObject o IDictionary<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.Trace para ver qué campos ignora UpdateSetAsync y 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:

  1. Revertir el cambio en el SQL (si fue un error)
  2. Crear un nuevo paso con el cambio deseado
  3. Cambiar política a DriftPolicy.Reapply si 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:

  • DateOnlyDateTime (columnas date)
  • TimeOnlyTimeSpan (columnas time)

La escritura pasa DateTime/TimeSpan tal cual a Npgsql, que se encarga de la conversión al tipo de columna destino.

Parámetros DateOnly / TimeOnly en IDirectQuery (v1.2.12+): Si pasas un DateOnly o TimeOnly (incluyendo nullables) como parámetro de Dapper en SQL crudo, los handlers fuerzan NpgsqlDbType.Date / NpgsqlDbType.Time automáticamente. Antes, la API genérica IDataMicroOrm<T> ya lo hacía (porque construye los NpgsqlParameter ella 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 a NodaTimeCompatibility.

Lectura DateOnly / TimeOnly con UseNodaTime() (v1.2.13+): Cuando UseNodaTime() está activo y un modelo tiene propiedades DateOnly / TimeOnly (o nullables) leídas desde columnas date / time, Npgsql devuelve LocalDate / LocalTime en vez del tipo nativo. El Parse de los handlers ahora cubre LocalDate → DateOnly, LocalDateTime → DateOnly (truncando la hora) y LocalTime → TimeOnly, evitando el InvalidCastException que 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 InstantDateTime timestamptz
Lectura LocalDateTimeDateTime timestamp
Lectura LocalDateDateTime date
Lectura LocalTimeTimeSpan time
Escritura DateTimeInstant timestamptz
Escritura TimeSpanLocalTime time

Limitación de escritura: El handler de DateTime convierte siempre a Instant (asume timestamptz). Para columnas date, usa DateOnly o LocalDate en tu modelo en lugar de DateTime.

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

  • v1.2.13: Lectura DateOnly / TimeOnly desde tipos NodaTime

    • 🐛 Bugfix: Los handlers añadidos en v1.2.12 (DateOnlyParameterHandler, NullableDateOnlyParameterHandler, TimeOnlyParameterHandler, NullableTimeOnlyParameterHandler) cubrían la escritura, pero su Parse no contemplaba que con UseNodaTime() activo PostgreSQL devuelve LocalDate / LocalTime en columnas date / time. Si un modelo tenía propiedades DateOnly / TimeOnly leídas en ese escenario, lanzaba InvalidCastException.
    • Fix: Parse ahora reconoce LocalDate → DateOnly, LocalDateTime → DateOnly (truncando la hora) y LocalTime → TimeOnly, alineando el comportamiento con InstantToDateTimeHandler de NodaTimeCompatibility.
  • v1.2.12: Handlers de parámetros DateOnly / TimeOnly para IDirectQuery

    • 🐛 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érica IDataMicroOrm<T> no estaba afectada porque construye los NpgsqlParameter directamente con NpgsqlDbType.Date / NpgsqlDbType.Time.
    • Fix: Nuevos SqlMapper.TypeHandler<DateOnly>, TypeHandler<DateOnly?>, TypeHandler<TimeOnly>, TypeHandler<TimeOnly?> que en SetValue fuerzan NpgsqlDbType.Date / NpgsqlDbType.Time (con fallback a DbType.Date / DbType.Time si el parámetro no es NpgsqlParameter). Los nullable mantienen el tipo aun cuando el valor es null, para que PostgreSQL no falle por tipo desconocido en WHERE col = @param.
    • Mejora: Registro automático tanto en NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility() como en NodaTimeCompatibility.EnableDateTimeCompatibility(). Los consumidores no necesitan cambios — la siguiente vez que arranquen quedan cubiertos.
  • v1.2.11: UpsertBulkAsync con claves de conflicto compuestas

    • Mejora: UpsertBulkAsync admite múltiples columnas como clave de conflicto (ON CONFLICT (col1, col2) DO UPDATE)
  • v1.2.8: UpsertBulkAsync — upserts masivos con COPY + ON CONFLICT

    • Nueva funcionalidad: UpsertBulkAsync combina la performance de Binary Import con la semántica de INSERT ... ON CONFLICT DO UPDATE. Internamente carga los datos a una tabla temporal con COPY y luego ejecuta el upsert.
  • v1.2.9: Nuevo SqlLiteralFormatter para 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 con CultureInfo.InvariantCulture y formato ISO 8601
      • Soporte completo BCL: DateTime (con semántica de Kind), 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)
    • Nueva utilidad pública: SqlLiteralFormatter.EscaparTexto(string) para escape manual como E-string
    • 🐛 Motivación (bugfix aguas arriba): Cuando UseNodaTime() está habilitado y se hace QueryAsync sin tipo genérico (devuelve DapperRow), Dapper no aplica TypeHandlers y llegan tipos NodaTime crudos. Formatearlos con .ToString() heredaba la cultura del proceso (p. ej. es-ES en contenedores) produciendo fechas "13/4/2026 18:32:50" que PostgreSQL rechaza con 22008: 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
  • v1.2.5: Consistencia en validaciones y trazas de diagnóstico

    • 🐛 Bugfix: GetByIdAsync no verificaba session.Connection antes de usarla (todos los demás métodos CRUD sí lo hacían). Ahora lanza InvalidOperationException con mensaje descriptivo si la sesión está cerrada.
    • Mejora: UpdateSetAsync emite 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: GetJsonPartAsync registra Trace.TraceWarning cuando falla la deserialización del fragmento JSON, incluyendo columna, ruta y mensaje de error. Antes devolvía null silenciosamente.
  • v1.2.4: Separación clara de handlers DateTime/NodaTime y correcciones internas

    • Mejora: NodaTimeCompatibility ahora maneja también TimeSpanLocalTime (columnas time)
    • Mejora: NpgsqlDateTimeCompatibility.SetValue ya no fuerza DateOnly — pasa DateTime tal cual para no romper columnas timestamptz
    • 🐛 Bugfix: EntityMap — la columna de soft delete no se excluía de WritableProps (error de orden en constructor)
    • 🐛 Bugfix: Regex en IsJsonbExpression y BuildJsonPathExpression se recompilaba en cada llamada (ahora es static readonly)
    • 🐛 Bugfix: [PgEnum] usaba NpgsqlDbType.Text en InsertBulkAsync vs NpgsqlDbType.Unknown en INSERT/UPDATE (unificado a Unknown)
    • Mejora: ResetParaReintentoAsync ahora loguea excepciones con LogWarning en vez de tragarlas silenciosamente
  • 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, RollbackAsync puede fallar y dejar _tx != null. Sin este método, BeginTransactionAsync lanzaría InvalidOperationException("Ya existe una transacción activa") en el reintento
  • v1.2.1: Corrección de doble serialización JSONB y compatibilidad con columnas date

    • 🐛 Bugfix crítico: Propiedades string con [Jsonb] se serializaban dos veces en INSERT/UPDATE
      • BuildWriteParameters llamaba JsonConvert.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, UpdateSetAsync e InsertBulkAsync
      • 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: NodaTimeCompatibility no manejaba columnas date ni timestamp (sin zona horaria)
      • Con UseNodaTime(), PostgreSQL date devuelve LocalDate y timestamp devuelve LocalDateTime, pero los handlers solo manejaban Instant (para timestamptz)
      • Modelos con DateTime/DateTime? para columnas date lanzaban InvalidCastException al leer
      • Fix: Añadidos LocalDate, LocalDateTime y DateOnly a InstantToDateTimeHandler y NullableInstantToDateTimeHandler
  • v1.2.0: Compatibilidad NodaTime, enums PostgreSQL, Dictionary JSONB y snake_case automático

    • Nueva funcionalidad: NodaTimeCompatibility.EnableDateTimeCompatibility() para modelos con DateTime cuando UseNodaTime() está habilitado
      • InstantToDateTimeHandler: convierte Instant/LocalDate/LocalDateTime/DateOnlyDateTime (lectura) y DateTimeInstant (escritura)
      • NullableInstantToDateTimeHandler: mismo comportamiento para DateTime?
      • Cubre columnas timestamptz, timestamp, date y DateOnly (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 → enum de PostgreSQL
      • Soportado en BuildWriteParameters, UpdateSetAsync y GetNpgsqlTypeAndValue
    • 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
    • Mejora: DirectQuery habilita MatchNamesWithUnderscores automáticamente
      • Mapea columnas snake_case → propiedades PascalCase sin configuración adicional
      • Se activa via constructor estático (una sola vez al cargar la clase)
  • v1.1.2: Mejoras en manejo de conexiones y depuración

    • Optimización en construcción de cadenas de conexión
    • Trazas detalladas en JsonExtensions para facilitar depuración
  • v1.1.1: Utilidades JSONB y TypeHandlers

    • Nueva funcionalidad: JsonValueConverter para manejo de valores JSONB
    • Mejoras en los TypeHandlers de Dapper para mayor compatibilidad con PostgreSQL
  • v1.1.0: Actualización a .NET 10, Npgsql 10.0.1, JSONB y seguridad

    • ⚠️ Breaking changes de Npgsql 10.0:
      • PostgreSQL date ahora mapea a DateOnly (antes DateTime)
      • PostgreSQL time ahora mapea a TimeOnly (antes TimeSpan)
      • PostgreSQL interval con meses/años no puede leerse como TimeSpan — usar NodaTime Period
      • cidr ahora mapea a IPNetwork (antes NpgsqlCidr)
    • Nueva funcionalidad: NpgsqlDateTimeCompatibility.EnableDateTimeCompatibility() para modelos existentes con DateTime/TimeSpan
      • Valida rango TimeSpan 0-24h para columnas time con error descriptivo
    • 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
    • Nueva funcionalidad: JsonExtensions — utilidades para trabajar con JSONB
      • GetPath<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
    • Nueva funcionalidad: GetJsonPartAsync<T>() — proyección parcial de campos JSONB
    • 🔒 Seguridad: Validación robusta en IsJsonbExpression() con regex estricto
    • 🐛 Bugfix: FlattenRecursive maneja correctamente JNull, JRaw, JUndefined
  • 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.
10
Gesgocom.NeuraNetGes
NeuraNetGes es una librería de apoyo para el uso de librerías de LLM.
13

.NET 10.0

Version Downloads Last updated
1.2.15 78 05/11/2026