Tu propio Hibernate: un mini ORM con anotaciones y reflexión
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
columnas(c): los atributos con@Columna, en el orden de declaración (getDeclaredFields), consetAccessible(true)porque sonprivate.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 KEYsi lleva@Id; si no,NOT NULLsi la columna tienenula = falseo el atributo es de un tipo primitivo (unintno puede ser null).cargar(c, fila): la fila es un mapa columna → texto. Si trae una columna que la clase no tiene: errorla tabla T no tiene la columna X. Si falta la columna del@Ido una connula = 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.- Errores de conversión, precedidos de
columna::«v» no es un int(odouble,boolean,Integer…, elgetSimpleNamedel tipo;booleansolo admitetrueyfalse),no puede ser NULL (el atributo es int)para NULL en un primitivo,no puede ser NULL (es la clave primaria)yno puede ser NULL (la columna es NOT NULL). Todos sonIllegalArgumentException; elmainlos escribe comoError en Clase: mensaje. insertar(o):INSERT INTO tabla (cols) VALUES (valores);con los valores así: NULL comoNULL, textos entre comillas simples con cada comilla doblada (O'Brien→'O''Brien'), booleanos comoTRUE/FALSEy números con sutoString.- El
mainydescribirya 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
| Clase y atributo | Columna | Tipo Java | Anotaciones |
|---|---|---|---|
| Alumno.id | id_alumno | int | @Id |
| Alumno.nombre | nombre | String | longitud 60, nula = false |
| Alumno.nota | nota | double | |
| Alumno.repetidor | repetidor | boolean | |
| Alumno.apodo | (no se guarda) | String | sin @Columna |
| Curso.codigo | codigo | String | @Id, longitud 10 |
| Curso.titulo | titulo | String | longitud 80, nula = false |
| Curso.horas | horas | int | |
| Curso.plazas | plazas | Integer | |
| 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 erroresErrores 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.
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.
Ejemplo
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
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 erroresSolución explicada
Ver la solución completa
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 elUPDATEde un objeto. - Añade una anotación
@Transitorioy salta esos atributos aunque lleven@Columna. - Soporta
LocalDatecomoDATEy unenumcomoVARCHARcon suname().