Skip to content

elialm7/kurubind

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

64 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KuruBind

CRUD sin ceremonia, SQL sin restricciones.

KuruBind es una libreria Java que elimina el boilerplate de CRUD sobre Jdbi 3. No es un ORM. No gestiona relaciones, no tiene lazy loading, no genera esquemas. Solo hace una cosa bien: mapear entidades a tablas y ejecutar operaciones basicas para que tu te concentres en las queries que importan.

Disenada para proyectos que eligen sus propias librerias — Javalin, Spark, Helidon, o cualquier backend Java sin Spring Boot.


Por que KuruBind

Con Jdbi puro escribes esto para cada entidad:

public Optional<User> findById(Long id) {
    return handle.createQuery("SELECT * FROM users WHERE id = :id")
            .bind("id", id)
            .mapTo(User.class)
            .findOne();
}

public User save(User user) {
    handle.createUpdate("INSERT INTO users (username, email, created_at) VALUES (:username, :email, :createdAt)")
            .bind("username", user.getUsername())
            .bind("email", user.getEmail())
            .bind("createdAt", Instant.now())
            .execute();
    return user;
}
// ... UPDATE, DELETE, COUNT, EXISTS — lo mismo para cada tabla

Con KuruBind:

KuruRepository<User> users = new KuruRepository<>(jdbi, User.class);

users.save(user);               // INSERT con timestamps automaticos
users.findById(1L);             // Optional<User>
users.findAll(0, 20);          // paginacion
users.count();

Las queries de negocio las sigues escribiendo en SQL directamente con Jdbi — KuruBind no se mete.


Instalacion

<dependency>
    <groupId>org.jdbi</groupId>
    <artifactId>jdbi3-core</artifactId>
    <version>3.49.0</version>
</dependency>

<dependency>
    <groupId>com.roelias</groupId>
    <artifactId>kurubind</artifactId>
    <version>1.1.0</version>
</dependency>

Inicio rapido

1. Configurar Jdbi

Jdbi jdbi = Jdbi.create(dataSource);
jdbi.registerRowMapper(new KurubindRowMapper.Factory());

2. Definir la entidad

@Kurubind
@Table("users")
public class User {

    @Id(generated = true)
    private Long id;

    private String username;
    private String email;

    @CreatedAt
    @Column("created_at")
    private Instant createdAt;

    @UpdatedAt
    @Column("updated_at")
    private Instant updatedAt;

    // getters y setters
}

3. Usar

KuruRepository<User> users = new KuruRepository<>(jdbi, User.class);

// CREATE
User user = new User();
user.setUsername("john");
user.setEmail("john@example.com");
users.save(user); // id y timestamps seteados automaticamente

// READ
Optional<User> found = users.findById(user.getId());
List<User> all     = users.findAll();
long count         = users.count();
boolean exists     = users.existsById(user.getId());

// PAGINACION
PageResult<User> page = users.findAll(Page.of(0, 20));

// UPDATE
user.setEmail("new@example.com");
users.save(user); // detecta que ya tiene ID → UPDATE, updatedAt actualizado

// DELETE
users.delete(user);
users.deleteById(1L);

Integracion con Javalin

public class Main {
    public static void main(String[] args) {
        Jdbi jdbi = Jdbi.create("jdbc:postgresql://localhost/mydb", "user", "pass");
        jdbi.registerRowMapper(new KurubindRowMapper.Factory());

        KuruRepository<User> users = new KuruRepository<>(jdbi, User.class);

        Javalin app = Javalin.create().start(8080);

        app.get("/users/{id}", ctx -> {
            long id = Long.parseLong(ctx.pathParam("id"));
            users.findById(id)
                 .ifPresentOrElse(ctx::json, () -> ctx.status(404));
        });

        app.post("/users", ctx -> {
            User user = ctx.bodyAsClass(User.class);
            ctx.json(users.save(user)).status(201);
        });

        app.get("/users", ctx -> {
            int page = Integer.parseInt(ctx.queryParamAsClass("page", String.class).getOrDefault("0"));
            int size = Integer.parseInt(ctx.queryParamAsClass("size", String.class).getOrDefault("20"));
            ctx.json(users.findAll(Page.of(page, size)));
        });
    }
}

Anotaciones

Anotacion Descripcion
@Kurubind Marca la clase como entidad de KuruBind (requerido)
@Table("nombre") Nombre de tabla. Acepta schema: @Table(value="users", schema="auth")
@Id Clave primaria. @Id(generated=true) para AUTO_INCREMENT/SERIAL
@Column("nombre") Mapea el campo a un nombre de columna especifico
@Transient Excluye el campo del mapeo
@CreatedAt Timestamp seteado automaticamente en INSERT
@UpdatedAt Timestamp seteado automaticamente en INSERT y UPDATE
@Generated("nombre") Aplica un generador por nombre registrado en GeneratorRegistry
@Generated(using=MiGen.class) Aplica un generador por clase — sin necesidad de registrarlo
@ForeignKey(references=Otro.class) Metadato de clave foranea

Filtros dinamicos

Filter construye clausulas WHERE dinamicas de forma segura, sin concatenar strings ni gestionar indices de parametros. Los valores null se ignoran automaticamente.

// AND implicito — condiciones nulas ignoradas
List<User> result = users.findAll(
    Filter.of()
          .eq("active", true)
          .like("email", "%@gmail.com")   // el usuario pasa el % manualmente
          .gt("created_at", since)
);

// OR
List<User> adminsOrMods = users.findAll(
    Filter.anyOf(
        Filter.of().eq("role", "admin"),
        Filter.of().eq("role", "mod")
    )
);

// IN — lista vacia ignorada automaticamente
List<User> byIds = users.findAll(
    Filter.of().in("id", List.of(1L, 2L, 3L))
);

// IS NULL / IS NOT NULL
List<User> sinEmail = users.findAll(
    Filter.of().isNull("email")
);

// Con ordenamiento
List<User> ordenados = users.findAll(
    Filter.of().like("email", "%@example.com")
               .orderBy("username", Order.ASC)
);

// Con paginacion
PageResult<User> pagina = users.findAll(
    Filter.of().eq("active", true),
    Page.of(0, 20)
);

// Count y exists con filtro
long total   = users.count(Filter.of().eq("role", "admin"));
boolean hay  = users.existsBy(Filter.of().eq("username", "john"));

Tambien disponible en KurubindDatabase para usarlo dentro de una transaccion compuesta:

jdbi.useTransaction(handle -> {
    var db = KurubindDatabase.of(handle);
    List<User> encontrados = db.findAll(User.class,
        Filter.of().eq("active", true).like("email", "%@corp.com"));
});

Paginacion

Page encapsula numero de pagina y tamano. PageResult incluye el contenido y el total de elementos.

PageResult<User> result = users.findAll(Page.of(0, 20));

result.content();        // List<User>
result.totalElements();  // long — total en la base de datos
result.currentPage();    // Page con numero y tamano actuales
result.nextPage();       // Page siguiente
result.previousPage();   // Page anterior (si existe)

Queries de negocio

Para todo lo que no es CRUD basico, usas Jdbi directamente — ya sea a traves del handle o combinado con KuruBind:

// Query personalizada con mapeo a la entidad
List<User> activos = users.query(
    "SELECT * FROM users WHERE active = :active AND created_at > :since",
    Map.of("active", true, "since", Instant.now().minusSeconds(86400))
);

// Query personalizada con paginacion
PageResult<User> pagina = users.query(
    "SELECT * FROM users WHERE role = :role",
    Map.of("role", "admin"),
    Page.of(0, 20)
);

// Query a un solo resultado
Optional<User> admin = users.queryOne(
    "SELECT * FROM users WHERE username = :username",
    Map.of("username", "admin")
);

Para queries que no mapean a una entidad o que involucran JOINs, usa Jdbi directamente:

jdbi.useHandle(handle -> {
    List<Map<String, Object>> stats = handle
        .createQuery("""
            SELECT u.username, COUNT(o.id) as orders
            FROM users u
            LEFT JOIN orders o ON o.user_id = u.id
            GROUP BY u.username
            """)
        .mapToMap()
        .list();
});

Transacciones

KuruBind usa Jdbi para transacciones. Las operaciones simples en KuruRepository se manejan automaticamente. Para operaciones compuestas que deben ser atomicas, usa el handle directamente:

// Operacion simple — transaccion automatica
users.save(user);

// Operacion compuesta — transaccion explicita con Jdbi
jdbi.useTransaction(handle -> {
    var db = KurubindDatabase.of(handle);

    db.save(user);
    db.save(order);
    // si algo falla, Jdbi hace rollback automaticamente
});

Proyecciones con Records

Para queries que devuelven columnas de multiples tablas o calculos, usa Java Records:

@Kurubind
public record UserSummary(
    Long id,
    String username,
    long orderCount,
    double totalSpent
) {}

// Uso
List<UserSummary> summaries = jdbi.withHandle(handle ->
    KurubindDatabase.of(handle).query("""
        SELECT u.id, u.username,
               COUNT(o.id)   as orderCount,
               COALESCE(SUM(o.total), 0) as totalSpent
        FROM users u
        LEFT JOIN orders o ON o.user_id = u.id
        GROUP BY u.id, u.username
        """,
        UserSummary.class,
        null
    )
);

Operaciones batch

List<User> nuevos = List.of(user1, user2, user3);

users.saveAll(nuevos);    // batch INSERT — IDs seteados en cada entidad
users.updateAll(nuevos);  // batch UPDATE
users.deleteAll(nuevos);  // batch DELETE

Las operaciones batch usan PreparedBatch de Jdbi internamente — una sola roundtrip a la base de datos.


Generadores personalizados

Un generador recibe tres argumentos: la entidad completa, los metadatos del campo, y el Handle de Jdbi activo en esa operacion.

@FunctionalInterface
public interface ValueGenerator {
    Object generate(Object entity, FieldMetadata field, Handle handle) throws Exception;
}

Hay dos formas de declarar un generador. Elige la que prefieras para cada caso.

Por clase (recomendado — type-safe, sin registro)

public class OrderCodeGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        long count = handle.createQuery("SELECT COUNT(*) FROM orders")
                .mapTo(Long.class).one();
        return "ORD-%08d".formatted(count + 1);
    }
}

@Generated(using = OrderCodeGenerator.class)
@Column("order_code")
private String orderCode;

Las instancias se crean una sola vez y se cachean — el generador debe ser stateless.

Por nombre (cuando necesitas registrarlo dinamicamente o compartirlo)

// Al inicio de la aplicacion
GeneratorRegistry.register("order_code", (entity, field, handle) -> {
    long count = handle.createQuery("SELECT COUNT(*) FROM orders")
            .mapTo(Long.class).one();
    return "ORD-%08d".formatted(count + 1);
});

@Generated("order_code")
@Column("order_code")
private String orderCode;

Usando entity — leer otros campos de la entidad

El parametro entity es la instancia completa que se esta guardando. Puedes castearlo para acceder a cualquier campo:

// Genera un slug a partir del titulo del articulo
public class SlugGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        Article article = (Article) entity;
        return article.getTitle()
                .toLowerCase()
                .replaceAll("[^a-z0-9]+", "-")
                .replaceAll("^-|-$", "");
    }
}

@Kurubind
@Table("articles")
public class Article {
    @Id(generated = true) private Long id;
    private String title;

    @Generated(using = SlugGenerator.class)
    private String slug;
    // ...
}

También puedes usar el entity para generar valores que dependen de multiples campos:

// Codigo de envio: PAIS-CIUDAD-UUID_CORTO
public class ShippingCodeGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        Order order = (Order) entity;
        String prefix = order.getCountry() + "-" + order.getCity();
        String uid    = UUID.randomUUID().toString().substring(0, 8).toUpperCase();
        return prefix + "-" + uid;
    }
}

Usando FieldMetadata — generadores reutilizables que se adaptan al tipo

FieldMetadata expone el tipo Java del campo (field.type()) y su nombre de columna (field.columnName()). Esto permite escribir un generador que funcione para multiples campos y tipos sin duplicar codigo:

// Un solo generador que funciona en campos String, UUID, Long y byte[]
public class UuidGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        UUID uuid = UUID.randomUUID();
        Class<?> type = field.type();

        if (type == String.class)  return uuid.toString();
        if (type == UUID.class)    return uuid;
        if (type == Long.class || type == long.class) return uuid.getMostSignificantBits() & Long.MAX_VALUE;
        if (type == byte[].class) {
            ByteBuffer buf = ByteBuffer.wrap(new byte[16]);
            buf.putLong(uuid.getMostSignificantBits());
            buf.putLong(uuid.getLeastSignificantBits());
            return buf.array();
        }
        throw new UnsupportedOperationException(
            "UuidGenerator no soporta el tipo: " + type.getName());
    }
}

// El mismo generador en campos de tipos distintos
@Generated(using = UuidGenerator.class)
private String correlationId;          // → "550e8400-e29b-41d4-..."

@Generated(using = UuidGenerator.class)
private UUID sessionToken;             // → UUID

@Generated(using = UuidGenerator.class)
private Long shortId;                  // → long positivo

Usando handle — consultas a la base de datos

El Handle es el mismo que esta activo en la operacion, por lo que las queries del generador participan en la misma transaccion:

// Secuencia basada en la base de datos
public class InvoiceNumberGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        int year = LocalDate.now().getYear();
        long seq  = handle.createQuery(
                "SELECT COUNT(*) + 1 FROM invoices WHERE YEAR(created_at) = :year")
                .bind("year", year)
                .mapTo(Long.class)
                .one();
        return "%d-%06d".formatted(year, seq);  // → "2026-000042"
    }
}

Solo si el campo esta vacio (no sobreescribir)

Un patron util cuando quieres un valor por defecto que el codigo puede pisar antes de guardar:

public class DefaultStatusGenerator implements ValueGenerator {
    @Override
    public Object generate(Object entity, FieldMetadata field, Handle handle) {
        Object current = field.getValue(entity);
        return current != null ? current : "pending";  // respeta el valor existente
    }
}

@Generated(using = DefaultStatusGenerator.class)
private String status;
// Si alguien llama order.setStatus("cancelled") antes de save(), se conserva

onInsert y onUpdate

Por defecto los generadores solo corren en INSERT. Para que tambien corran en UPDATE:

@Generated(using = HashGenerator.class, onInsert = true, onUpdate = true)
@Column("content_hash")
private String contentHash;

// O en solo UPDATE (equivale a @UpdatedAt pero con logica personalizada)
@Generated(using = VersionGenerator.class, onInsert = false, onUpdate = true)
private int version;

Meta-anotaciones

Puedes componer generadores en anotaciones propias para reutilizarlos sin repetir el using:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Generated(using = UuidGenerator.class)
public @interface AutoUuid {}

// Uso
@AutoUuid
private String correlationId;

Funciona de la misma manera que @CreatedAt y @UpdatedAt que ya incluye KuruBind.


Bases de datos soportadas

KuruBind detecta el dialecto automaticamente desde los metadatos del driver JDBC.

Base de datos Soporte
PostgreSQL Completo (incluye RETURNING para IDs generados)
MySQL / MariaDB Completo
H2 Completo (ideal para tests)
SQLite Completo
SQL Server Completo (incluye OUTPUT INSERTED para IDs generados)
Generic (ANSI SQL) Fallback para cualquier otra base de datos

Para forzar un dialecto especifico en lugar de auto-deteccion:

KuruRepository<User> users = new KuruRepository<>(jdbi, User.class)
        .withDialect(new PostgresDialect());

Schema por defecto

Si todas tus tablas viven en el mismo schema puedes configurarlo una vez en lugar de repetirlo en cada @Table:

KuruRepository<User> users = new KuruRepository<>(jdbi, User.class)
        .withDefaultSchema("myapp");
// genera: SELECT * FROM "myapp"."users" ...

Las entidades que ya tienen @Table(schema = "otro") conservan su schema explicito. El schema por defecto solo aplica cuando la entidad no especifica uno.


KurubindDatabase vs KuruRepository

La libreria expone dos niveles de API:

KurubindDatabase — API de bajo nivel, recibe un Handle de Jdbi. Usalo cuando necesitas control total sobre el handle o quieres componer operaciones en una misma transaccion.

jdbi.useTransaction(handle -> {
    var db = KurubindDatabase.of(handle);
    db.save(user);
    db.save(order);
});

KuruRepository<T> — API de alto nivel, recibe un Jdbi. Gestiona handles automaticamente: reads con withHandle, writes con inTransaction. Ideal para el 90% de los casos.

KuruRepository<User> users = new KuruRepository<>(jdbi, User.class);
users.save(user);
users.findById(1L);

Licencia

Apache 2.0

About

A lightweight, plugin-based data mapping library for Java that eliminates CRUD boilerplate while keeping you close to SQL. Built on JDBI with annotation-driven entity mapping.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages