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.
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 tablaCon 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.
<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>Jdbi jdbi = Jdbi.create(dataSource);
jdbi.registerRowMapper(new KurubindRowMapper.Factory());@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
}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);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)));
});
}
}| 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 |
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"));
});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)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();
});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
});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
)
);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 DELETELas operaciones batch usan PreparedBatch de Jdbi internamente — una sola roundtrip a la base de datos.
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.
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.
// 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;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;
}
}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 positivoEl 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"
}
}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 conservaPor 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;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.
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());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.
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);Apache 2.0