Apuntes DAM
Volver al inicio

Tu propio Hibernate: un mini ORM con anotaciones y reflexión

Ejercicio de JavaDifícilUnos 75 minutos

Construye el corazón de un ORM como Hibernate: con anotaciones propias (@Entidad, @Columna, @Id) y reflexión, genera el CREATE TABLE de cada clase, convierte filas de texto en objetos con sus tipos y escribe el INSERT de cada objeto, con los nulos, los primitivos y las comillas bien tratados.

  • Anotaciones propias y @Retention
  • Reflexión: Field, getDeclaredFields, setAccessible
  • Mapeo objeto-relacional
  • Tipos Java a tipos SQL
  • int frente a Integer y NULL
  • Escapar comillas en SQL

Enunciado

Hibernate y JPA parecen magia: anotas una clase con @Entity y @Column y el framework crea la tabla, guarda los objetos y los recupera. Por dentro no hay magia, sino reflexión: Java permite leer en tiempo de ejecución qué atributos tiene una clase, de qué tipo son, qué anotaciones llevan, y leer o escribir sus valores aunque sean private.

En este ejercicio las anotaciones y las clases ya están escritas (en el código de partida): @Entidad(tabla) marca una clase que se guarda en una tabla, @Columna(nombre, longitud, nula) un atributo que se guarda en una columna y @Id la clave primaria. Tú escribes la clase Orm, que es la que hace el trabajo. El main ya está: crea las tablas, lee filas de la entrada y, por cada una, crea el objeto y escribe su INSERT.

Qué tiene que hacer el programa

  1. columnas(c): los atributos con @Columna, en el orden de declaración (getDeclaredFields), con setAccessible(true) porque son private.
  2. crearTabla(c): CREATE TABLE tabla (col TIPO, ...);. Tipos: int/Integer → INT, long/Long → BIGINT, double/Double → DOUBLE, boolean/Boolean → BOOLEAN, String → VARCHAR(longitud). Tras el tipo, PRIMARY KEY si lleva @Id; si no, NOT NULL si la columna tiene nula = false o el atributo es de un tipo primitivo (un int no puede ser null).
  3. cargar(c, fila): la fila es un mapa columna → texto. Si trae una columna que la clase no tiene: error la tabla T no tiene la columna X. Si falta la columna del @Id o una con nula = false: falta la columna X; si falta otra, el atributo se queda con su valor por defecto. Cada texto se convierte al tipo del atributo; null (en cualquier mayúscula) es NULL.
  4. Errores de conversión, precedidos de columna: : «v» no es un int (o double, boolean, Integer…, el getSimpleName del tipo; boolean solo admite true y false), no puede ser NULL (el atributo es int) para NULL en un primitivo, no puede ser NULL (es la clave primaria) y no puede ser NULL (la columna es NOT NULL). Todos son IllegalArgumentException; el main los escribe como Error en Clase: mensaje.
  5. insertar(o): INSERT INTO tabla (cols) VALUES (valores); con los valores así: NULL como NULL, textos entre comillas simples con cada comilla doblada (O'Brien → 'O''Brien'), booleanos como TRUE/FALSE y números con su toString.
  6. El main y describir ya están hechos: no los cambies.

Entrada

Cada línea: el nombre de la clase y, separados por ;, pares columna=valor (con los nombres de las columnas, no de los atributos).

Datos de referencia

Mapeo de las clases
Clase y atributoColumnaTipo JavaAnotaciones
Alumno.idid_alumnoint@Id
Alumno.nombrenombreStringlongitud 60, nula = false
Alumno.notanotadouble
Alumno.repetidorrepetidorboolean
Alumno.apodo(no se guarda)Stringsin @Columna
Curso.codigocodigoString@Id, longitud 10
Curso.titulotituloStringlongitud 80, nula = false
Curso.horashorasint
Curso.plazasplazasInteger
Profesor(no es entidad)sin @Entidad

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Alumnos y cursos

Entrada

Alumno id_alumno=1; nombre=Ana O'Brien; nota=8.5; repetidor=false
Alumno id_alumno=2; nombre=Luis Pérez; nota=6
Curso codigo=DAM1; titulo=Programación; horas=256; plazas=30
Curso codigo=DAW2; titulo=Despliegue; horas=88; plazas=null

Salida por consola

CREATE TABLE alumnos (id_alumno INT PRIMARY KEY, nombre VARCHAR(60) NOT NULL, nota DOUBLE NOT NULL, repetidor BOOLEAN NOT NULL);
CREATE TABLE cursos (codigo VARCHAR(10) PRIMARY KEY, titulo VARCHAR(80) NOT NULL, horas INT NOT NULL, plazas INT);
Alumno{id=1, nombre=Ana O'Brien, nota=8.5, repetidor=false, apodo=null}
INSERT INTO alumnos (id_alumno, nombre, nota, repetidor) VALUES (1, 'Ana O''Brien', 8.5, FALSE);
Alumno{id=2, nombre=Luis Pérez, nota=6.0, repetidor=false, apodo=null}
INSERT INTO alumnos (id_alumno, nombre, nota, repetidor) VALUES (2, 'Luis Pérez', 6.0, FALSE);
Curso{codigo=DAM1, titulo=Programación, horas=256, plazas=30}
INSERT INTO cursos (codigo, titulo, horas, plazas) VALUES ('DAM1', 'Programación', 256, 30);
Curso{codigo=DAW2, titulo=Despliegue, horas=88, plazas=null}
INSERT INTO cursos (codigo, titulo, horas, plazas) VALUES ('DAW2', 'Despliegue', 88, NULL);
4 insertados, 0 con errores

Errores de conversión y de mapeo

Entrada

Alumno id_alumno=tres; nombre=Eva
Alumno nombre=Sin Id; nota=5
Curso codigo=X1; titulo=Redes; horas=null
Profesor nombre=Marta
Aula numero=12
Alumno id_alumno=4; nombre=Rosa; email=rosa@ies.es
Alumno id_alumno=5; nombre=Iker; repetidor=quizá

Salida por consola

CREATE TABLE alumnos (id_alumno INT PRIMARY KEY, nombre VARCHAR(60) NOT NULL, nota DOUBLE NOT NULL, repetidor BOOLEAN NOT NULL);
CREATE TABLE cursos (codigo VARCHAR(10) PRIMARY KEY, titulo VARCHAR(80) NOT NULL, horas INT NOT NULL, plazas INT);
Error en Alumno: id_alumno: «tres» no es un int
Error en Alumno: falta la columna id_alumno
Error en Curso: horas: no puede ser NULL (el atributo es int)
Profesor no es una entidad (le falta @Entidad)
Clase desconocida: Aula
Error en Alumno: la tabla alumnos no tiene la columna email
Error en Alumno: repetidor: «quizá» no es un boolean
0 insertados, 7 con errores

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Leer las anotaciones

f.isAnnotationPresent(Columna.class) dice si el atributo la lleva y f.getAnnotation(Columna.class) la devuelve, con sus valores como métodos: col.nombre(), col.longitud(). Solo funciona porque las anotaciones tienen @Retention(RUNTIME).

2. Del tipo Java al SQL

f.getType() devuelve un Class: compáralo con int.class, Integer.class, String.class… y isPrimitive() dice si es un primitivo.

3. Crear y rellenar el objeto

El constructor sin parámetros se obtiene con getDeclaredConstructor().newInstance(). Después, para cada columna, convierte el texto y asígnalo con f.set(obj, valor): Java desenvuelve un Integer en un int solo.

java
T obj = c.getDeclaredConstructor().newInstance();
for (Field f : columnas(c)) {
    String texto = fila.get(f.getAnnotation(Columna.class).nombre());
    f.set(obj, convertir(texto, f.getType()));
}
4. El INSERT

Recorre las columnas otra vez, ahora leyendo con f.get(o), y formatea cada valor según su clase con instanceof.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

☕JavaTu propio Hibernate: un mini ORM con anotaciones y reflexiónDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
Alumno id_alumno=1; nombre=Ana O'Brien; nota=8.5; repetidor=false
Alumno id_alumno=2; nombre=Luis Pérez; nota=6
Curso codigo=DAM1; titulo=Programación; horas=256; plazas=30
Curso codigo=DAW2; titulo=Despliegue; horas=88; plazas=null
Salida esperada
CREATE TABLE alumnos (id_alumno INT PRIMARY KEY, nombre VARCHAR(60) NOT NULL, nota DOUBLE NOT NULL, repetidor BOOLEAN NOT NULL);
CREATE TABLE cursos (codigo VARCHAR(10) PRIMARY KEY, titulo VARCHAR(80) NOT NULL, horas INT NOT NULL, plazas INT);
Alumno{id=1, nombre=Ana O'Brien, nota=8.5, repetidor=false, apodo=null}
INSERT INTO alumnos (id_alumno, nombre, nota, repetidor) VALUES (1, 'Ana O''Brien', 8.5, FALSE);
Alumno{id=2, nombre=Luis Pérez, nota=6.0, repetidor=false, apodo=null}
INSERT INTO alumnos (id_alumno, nombre, nota, repetidor) VALUES (2, 'Luis Pérez', 6.0, FALSE);
Curso{codigo=DAM1, titulo=Programación, horas=256, plazas=30}
INSERT INTO cursos (codigo, titulo, horas, plazas) VALUES ('DAM1', 'Programación', 256, 30);
Curso{codigo=DAW2, titulo=Despliegue, horas=88, plazas=null}
INSERT INTO cursos (codigo, titulo, horas, plazas) VALUES ('DAW2', 'Despliegue', 88, NULL);
4 insertados, 0 con errores
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
1import java.lang.annotation.*;
2import java.lang.reflect.*;
3import java.util.*;
4
5// ── Las anotaciones del ORM (como @Entity, @Table, @Column e @Id de JPA) ──
6@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
7@interface Entidad { String tabla(); }
8
9@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
10@interface Columna { String nombre(); int longitud() default 255; boolean nula() default true; }
11
12@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
13@interface Id { }
14
15// ── Las clases mapeadas ──
16@Entidad(tabla = "alumnos")
17class Alumno {
18    @Id @Columna(nombre = "id_alumno") private int id;
19    @Columna(nombre = "nombre", longitud = 60, nula = false) private String nombre;
20    @Columna(nombre = "nota") private double nota;
21    @Columna(nombre = "repetidor") private boolean repetidor;
22    private String apodo;               // sin @Columna: no se guarda (como @Transient)
23}
24
25@Entidad(tabla = "cursos")
26class Curso {
27    @Id @Columna(nombre = "codigo", longitud = 10) private String codigo;
28    @Columna(nombre = "titulo", longitud = 80, nula = false) private String titulo;
29    @Columna(nombre = "horas") private int horas;
30    @Columna(nombre = "plazas") private Integer plazas;    // Integer, no int: admite NULL
31}
32
33class Profesor {                        // sin @Entidad: el ORM no sabe guardarla
34    private String nombre;
35}
36
37class Orm {
38
39    /** Los atributos con @Columna, en el orden en que están declarados. */
40    static List<Field> columnas(Class<?> c) {
41        List<Field> r = new ArrayList<>();
42        for (Field f : c.getDeclaredFields()) {
43            if (f.isAnnotationPresent(Columna.class)) {
44                f.setAccessible(true);
45                r.add(f);
46            }
47        }
48        return r;
49    }
50
51    static String tipoSql(Field f) {
52        Class<?> t = f.getType();
53        if (t == int.class || t == Integer.class) return "INT";
54        if (t == long.class || t == Long.class) return "BIGINT";
55        if (t == double.class || t == Double.class) return "DOUBLE";
56        if (t == boolean.class || t == Boolean.class) return "BOOLEAN";
57        if (t == String.class) return "VARCHAR(" + f.getAnnotation(Columna.class).longitud() + ")";
58        throw new IllegalArgumentException("tipo no soportado: " + t.getSimpleName());
59    }
60
61    static String crearTabla(Class<?> c) {
62        List<String> defs = new ArrayList<>();
63        for (Field f : columnas(c)) {
64            Columna col = f.getAnnotation(Columna.class);
65            String d = col.nombre() + " " + tipoSql(f);
66            if (f.isAnnotationPresent(Id.class)) d += " PRIMARY KEY";
67            else if (!col.nula() || f.getType().isPrimitive()) d += " NOT NULL";   // un int nunca es null
68            defs.add(d);
69        }
70        return "CREATE TABLE " + c.getAnnotation(Entidad.class).tabla() + " (" + String.join(", ", defs) + ");";
71    }
72
73    /** Convierte el texto al tipo del atributo; «null» es NULL salvo en los tipos primitivos. */
74    static Object convertir(String valor, Class<?> t) {
75        if (valor.equalsIgnoreCase("null")) {
76            if (t.isPrimitive()) throw new IllegalArgumentException("no puede ser NULL (el atributo es " + t.getSimpleName() + ")");
77            return null;
78        }
79        try {
80            if (t == int.class || t == Integer.class) return Integer.parseInt(valor);
81            if (t == long.class || t == Long.class) return Long.parseLong(valor);
82            if (t == double.class || t == Double.class) return Double.parseDouble(valor);
83        } catch (NumberFormatException e) {
84            throw new IllegalArgumentException("«" + valor + "» no es un " + t.getSimpleName());
85        }
86        if (t == boolean.class || t == Boolean.class) {
87            if (valor.equalsIgnoreCase("true")) return true;
88            if (valor.equalsIgnoreCase("false")) return false;
89            throw new IllegalArgumentException("«" + valor + "» no es un " + t.getSimpleName());
90        }
91        return valor;
92    }
93
94    /** Crea el objeto con su constructor sin parámetros y rellena cada atributo con su columna de la fila. */
95    static <T> T cargar(Class<T> c, Map<String, String> fila) throws ReflectiveOperationException {
96        String tabla = c.getAnnotation(Entidad.class).tabla();
97        List<Field> cols = columnas(c);
98        for (String nombre : fila.keySet()) {
99            if (cols.stream().noneMatch(f -> f.getAnnotation(Columna.class).nombre().equals(nombre)))
100                throw new IllegalArgumentException("la tabla " + tabla + " no tiene la columna " + nombre);
101        }
102        T obj = c.getDeclaredConstructor().newInstance();
103        for (Field f : cols) {
104            Columna col = f.getAnnotation(Columna.class);
105            boolean id = f.isAnnotationPresent(Id.class);
106            String texto = fila.get(col.nombre());
107            if (texto == null) {
108                if (id || !col.nula()) throw new IllegalArgumentException("falta la columna " + col.nombre());
109                continue;               // se queda el valor por defecto: 0, false o null
110            }
111            Object valor;
112            try {
113                valor = convertir(texto, f.getType());
114            } catch (IllegalArgumentException e) {
115                throw new IllegalArgumentException(col.nombre() + ": " + e.getMessage());
116            }
117            if (valor == null && id) throw new IllegalArgumentException(col.nombre() + ": no puede ser NULL (es la clave primaria)");
118            if (valor == null && !col.nula()) throw new IllegalArgumentException(col.nombre() + ": no puede ser NULL (la columna es NOT NULL)");
119            f.set(obj, valor);
120        }
121        return obj;
122    }
123
124    static String sql(Object v) {
125        if (v == null) return "NULL";
126        if (v instanceof String s) return "'" + s.replace("'", "''") + "'";
127        if (v instanceof Boolean b) return b ? "TRUE" : "FALSE";
128        return v.toString();
129    }
130
131    static String insertar(Object o) throws IllegalAccessException {
132        List<String> cols = new ArrayList<>(), valores = new ArrayList<>();
133        for (Field f : columnas(o.getClass())) {
134            cols.add(f.getAnnotation(Columna.class).nombre());
135            valores.add(sql(f.get(o)));
136        }
137        return "INSERT INTO " + o.getClass().getAnnotation(Entidad.class).tabla()
138                + " (" + String.join(", ", cols) + ") VALUES (" + String.join(", ", valores) + ");";
139    }
140
141    /** Todos los atributos del objeto, mapeados o no: Alumno{id=1, nombre=Ana, ...}. */
142    static String describir(Object o) throws IllegalAccessException {
143        List<String> partes = new ArrayList<>();
144        for (Field f : o.getClass().getDeclaredFields()) {
145            f.setAccessible(true);      // los atributos son private
146            partes.add(f.getName() + "=" + f.get(o));
147        }
148        return o.getClass().getSimpleName() + "{" + String.join(", ", partes) + "}";
149    }
150}
151
152public class Main {
153    static final List<Class<?>> CLASES = List.of(Alumno.class, Curso.class, Profesor.class);
154
155    public static void main(String[] args) throws Exception {
156        for (Class<?> c : CLASES) {
157            if (c.isAnnotationPresent(Entidad.class)) System.out.println(Orm.crearTabla(c));
158        }
159        Scanner sc = new Scanner(System.in);
160        int insertados = 0, errores = 0;
161        while (sc.hasNextLine()) {
162            String linea = sc.nextLine().trim();
163            if (linea.isEmpty()) continue;
164            String[] p = linea.split("\\s+", 2);
165            Class<?> c = CLASES.stream().filter(k -> k.getSimpleName().equals(p[0])).findFirst().orElse(null);
166            if (c == null) {
167                System.out.println("Clase desconocida: " + p[0]);
168                errores++;
169                continue;
170            }
171            if (!c.isAnnotationPresent(Entidad.class)) {
172                System.out.println(p[0] + " no es una entidad (le falta @Entidad)");
173                errores++;
174                continue;
175            }
176            Map<String, String> fila = new LinkedHashMap<>();
177            if (p.length > 1) {
178                for (String par : p[1].split(";")) {
179                    int i = par.indexOf('=');
180                    if (i > 0) fila.put(par.substring(0, i).trim(), par.substring(i + 1).trim());
181                }
182            }
183            try {
184                Object o = Orm.cargar(c, fila);
185                System.out.println(Orm.describir(o));
186                System.out.println(Orm.insertar(o));
187                insertados++;
188            } catch (IllegalArgumentException e) {
189                System.out.println("Error en " + p[0] + ": " + e.getMessage());
190                errores++;
191            }
192        }
193        System.out.println(insertados + " insertados, " + errores + " con errores");
194    }
195}

Así trabaja Hibernate: al arrancar lee por reflexión las anotaciones de cada entidad y construye su metamodelo (qué tabla, qué columnas, qué tipos); al guardar, lee los atributos del objeto con Field.get y monta el SQL; al cargar, crea el objeto con el constructor vacío (por eso JPA exige uno) y rellena los atributos con Field.set.

La diferencia entre int e Integer es la diferencia entre NOT NULL y NULL: un int siempre tiene valor (0 por defecto), así que una columna que puede ser NULL debe mapearse a la clase envoltorio.

Doblar las comillas en el INSERT evita que un O'Brien rompa la consulta, pero un ORM real no lo hace así: usa PreparedStatement con parámetros, que es lo seguro frente a la inyección SQL.

La especificación de Java no garantiza el orden de getDeclaredFields (aunque la JVM habitual respeta el de la declaración); por eso JPA no depende de él y ordena las columnas por su cuenta.

Para ir más allá

  • Genera también el SELECT ... WHERE id = ? y el UPDATE de un objeto.
  • Añade una anotación @Transitorio y salta esos atributos aunque lleven @Columna.
  • Soporta LocalDate como DATE y un enum como VARCHAR con su name().

Dónde se explica