Ingeniería inversa de entidades JPA El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub
springboot-demo-projects , bajo el tag
persistence-integration.
El Problema
Escribir clases de entidades JPA a mano desde un esquema de base de datos existente es tedioso. Si tu esquema tiene 20 tablas conseguís 20 archivos de entidades para crear, anotar, y mantener sincronizados cada vez que el esquema cambia. Faltá una columna, obtené un nullable incorrecto, u olvídáte una relación y estás debuggeando en runtime.
La alternativa: generar las entidades en tiempo de compilación directamente desde el esquema SQL. Sin necesidad de una base de datos corriendo. Sin escritura de entidades manual. El build lee el esquema, levanta una instancia H2 in-memory, la introspecciona, y escribe los archivos fuente de entidades antes de que tu código ni siquiera compile.
Cómo Funciona
Una configuración de dependencias Gradle dedicada hibernateTools trae las herramientas de generación de código, completamente aislada de tu classpath de compilación y runtime.
La tarea Gradle generateEntities carga tu esquema SQL en una base de datos H2 in-memory, conecta vía JDBC, y usa Hibernate Tools para introspeccionar el esquema y escribir archivos fuente de entidades en build/generated/sources/hibernate/.
Ese directorio generado se registra como una root de fuente adicional, entonces las entidades compilan transparentemente junto a tu código escrito a mano.
La tarea se configura para correr antes de compileJava / compileKotlin / compileGroovy, entonces los tipos generados siempre están disponibles en tiempo de compilación.
Archivos Nuevos
Archivos a Crear/Modificar
File Tree
. ├── build.gradle # or build.gradle.kts in kotlin ├── ... # other root files omitted └── src ├── main │ ├── ... # source code omitted │ └── resources │ ├── application-dev.yaml │ ├── application.yaml │ ├── hibernate.reveng.xml │ ├── hibernate-tools.properties │ ├── logback-spring.xml │ ├── openapi.yaml │ ├── sakila-data.sql │ ├── sakila-schema.sql │ └── templates/hibernate/pojo/Pojo.ftl └── test/... # test sources omitted
Expand(7 more lines)
Las adiciones resaltadas caen en dos grupos: archivos de recursos que conducen la generación (hibernate.reveng.xml, hibernate-tools.properties, Pojo.ftl, los archivos de esquema SQL y data) y los cambios en build.gradle que conectan todo.
Paso 1 — Agregá Archivos de Recursos
sakila-schema.sql
Esta es la fuente de verdad. El ejemplo que venimos siguiendo en esta guía usa la base de datos de muestra Sakila , pero el setup mismo aplica a cualquier esquema con el que estés trabajando. Solo reemplazá sakila-schema.sql por tu propio archivo DDL y actualizá la tarea en consecuencia.
Hibernate Tools nunca se conecta a tu base de datos real. Solo lee este archivo SQL, lo carga en una instancia H2 in-memory, y la introspecciona. Este archivo debe reflejar fielmente el esquema de producción. Si producción tiene una columna que tu archivo SQL no tiene, la entidad generada no la tendrá. Si los tipos no coinciden, los mapeos generados estarán mal. Tratá este archivo con el cuidado que le darías a la base de datos misma.
hibernate.reveng.xml
resources/hibernate.reveng.xml
<?xml version="1.0" encoding="UTF-8"?> <! DOCTYPE hibernate-reverse-engineering PUBLIC "-//Hibernate/Hibernate Reverse Engineering DTD 3.0//EN" "http://hibernate.org/dtd/hibernate-reverse-engineering-3.0.dtd" > < hibernate-reverse-engineering > < type-mapping > < sql-type jdbc-type = " TINYINT " hibernate-type = " java.lang.Integer " /> < sql-type jdbc-type = " SMALLINT " hibernate-type = " java.lang.Integer " /> < sql-type jdbc-type = " BIT " hibernate-type = " java.lang.Boolean " /> < sql-type jdbc-type = " TIMESTAMP " hibernate-type = " java.time.LocalDateTime " /> < sql-type jdbc-type = " DATE " hibernate-type = " java.time.LocalDate " /> </ type-mapping > < table-filter match-schema = " PUBLIC " match-name = " .* " /> </ hibernate-reverse-engineering >
Expand(3 more lines)
El bloque <type-mapping> sobreescribe los mapeos de tipo JDBC-a-Java por defecto. Sin él, Hibernate Tools mapearía TIMESTAMP a un raw java.sql.Timestamp y TINYINT a un primitive byte. Las sobreescrituras te dan equivalentes java.time.* limpios y tipos boxed apropiados en su lugar.
La línea <table-filter> le dice a Hibernate Tools que haga reverse-engineer de cada tabla en el esquema PUBLIC, que es todo de Sakila.
resources/hibernate-tools.properties
hibernate.connection.driver_class=org.h2.Driver hibernate.connection.username=sa hibernate.connection.password= hibernate.dialect=org.hibernate.dialect.H2Dialect hibernate.connection.provider_class=org.hibernate.connection.DriverManagerConnectionProvider
Este archivo suministra los detalles de conexión JDBC estáticos. Fijate que no hay hibernate.connection.url acá. Esa propiedad se inyecta dinámicamente por la tarea Gradle en tiempo de generación, apuntando al archivo de esquema SQL vía una connection string H2 INIT=RUNSCRIPT. Mantenerlo fuera de este archivo evita hardcodear una ruta absoluta.
Pojo.ftl
resources/templates/hibernate/pojo/Pojo.ftl
<#-- Hibernate Tools 6.x Compatible Template --> <#-- Available objects: pojo, clazz --> ${pojo.getPackageDeclaration()} import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; <#-- Determine if this is a composite key class (Embeddable) --> <#assign className = pojo.getDeclarationName()> <#assign isCompositeKey = !pojo.hasIdentifierProperty() && className?ends_with("Id")> <#-- For entities: check if they use a composite key --> <#assign usesCompositeKey = false> <#assign compositeKeyTypeName = ""> <#assign compositeKeyFields = []> <#if !isCompositeKey> <#list pojo.getAllPropertiesIterator() as property> <#assign javaType = pojo.getJavaTypeName(property, true)> <#if property.name == "id" && javaType?ends_with("Id")> <#assign usesCompositeKey = true> <#assign compositeKeyTypeName = javaType> </#if> </#list> </#if> /** * ${className} generated by Hibernate Tools */ <#if isCompositeKey> @Embeddable @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @EqualsAndHashCode public class ${className} implements Serializable { <#else> @Entity @Table(name = "${clazz.table.name}"<#if clazz.table.schema?? && clazz.table.schema?has_content>, schema = "${clazz.table.schema}"</#if>) @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class ${className} implements Serializable { </#if> @Serial private static final long serialVersionUID = 1L; <#-- Iterate over all properties --> <#list pojo.getAllPropertiesIterator() as property> <#assign propertyName = property.name> <#assign javaType = pojo.getJavaTypeName(property, true)> <#assign valueTypeName = property.value.class.simpleName> <#-- Check if this is the identifier --> <#assign isId = pojo.hasIdentifierProperty() && pojo.getIdentifierProperty().name == propertyName> <#-- Check if this is a composite/embedded id --> <#assign isEmbeddedId = propertyName == "id" && javaType?ends_with("Id")> <#if isCompositeKey> <#-- For composite key classes, just generate columns without @Id --> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> private ${javaType} ${propertyName}; <#else> <#-- Regular entity logic --> <#-- Handle EmbeddedId (composite primary key reference) --> <#if isEmbeddedId> @EmbeddedId private ${javaType} ${propertyName}; <#-- Generate @Id annotation for simple primary key --> <#elseif isId> @Id @GeneratedValue(strategy = GenerationType.IDENTITY) <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}") <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}") </#if> </#if> private ${javaType} ${propertyName}; <#-- Handle ManyToOne relationships --> <#elseif valueTypeName == "ManyToOne"> @ManyToOne(fetch = FetchType.LAZY) <#-- Determine column name for potential @MapsId --> <#assign columnName = ""> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> <#assign columnName = column.name> <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> <#assign columnName = column.name> </#if> </#if> <#-- If entity uses composite key, add @MapsId --> <#if usesCompositeKey && columnName?has_content> <#-- Convert ACTOR_ID -> actorId for @MapsId value --> <#assign mapsIdValue = columnName?lower_case?replace("_", " ")?capitalize?replace(" ", "")?uncap_first> @MapsId("${mapsIdValue}") </#if> <#if columnName?has_content> @JoinColumn(name = "${columnName}") </#if> private ${javaType} ${propertyName}; <#-- Handle OneToMany relationships (Set, Bag, List) --> <#elseif valueTypeName == "Set" || valueTypeName == "Bag" || valueTypeName == "List"> <#-- Derive mappedBy value from property name pattern. Property naming pattern: {targetEntity}For{ColumnSuffix} (e.g., filmsForLanguageId) Inverse property pattern: {thisEntity}By{ColumnSuffix} (e.g., languageByLanguageId) --> <#assign mappedByValue = className?uncap_first> <#if propertyName?contains("For")> <#assign forIndex = propertyName?index_of("For")> <#if (forIndex > 0) && (forIndex < propertyName?length - 3)> <#assign columnSuffix = propertyName?substring(forIndex + 3)> <#assign mappedByValue = className?uncap_first + "By" + columnSuffix> </#if> </#if> @OneToMany(fetch = FetchType.LAZY, mappedBy = "${mappedByValue}") @Builder.Default private ${javaType} ${propertyName} = new HashSet<>(0); <#-- Handle regular columns (BasicValue, SimpleValue) --> <#elseif valueTypeName == "BasicValue" || valueTypeName == "SimpleValue"> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if><#if (column.length)?? && column.length != 255 && javaType == "String">, length = ${column.length?c}</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> private ${javaType} ${propertyName}; <#-- Handle Component/Embedded types --> <#elseif valueTypeName == "Component"> @Embedded private ${javaType} ${propertyName}; <#-- Fallback for unknown types --> <#else> // TODO: Unknown mapping type "${valueTypeName}" for property "${propertyName}" private ${javaType} ${propertyName}; </#if> </#if> </#list> }
Expand(182 more lines) resources/templates/hibernate/pojo/Pojo.ftl
<#-- Hibernate Tools 6.x Compatible Template for Kotlin --> <#-- Available objects: pojo, clazz --> ${pojo.getPackageDeclaration()} import jakarta.persistence.* import java.io.Serializable import java.math.BigDecimal import java.time.LocalDate import java.time.LocalDateTime <#-- Determine if this is a composite key class (Embeddable) --> <#assign className = pojo.getDeclarationName()> <#assign isCompositeKey = !pojo.hasIdentifierProperty() && className?ends_with("Id")> <#-- For entities: check if they use a composite key --> <#assign usesCompositeKey = false> <#assign compositeKeyTypeName = ""> <#if !isCompositeKey> <#list pojo.getAllPropertiesIterator() as property> <#assign javaType = pojo.getJavaTypeName(property, true)> <#if property.name == "id" && javaType?ends_with("Id")> <#assign usesCompositeKey = true> <#assign compositeKeyTypeName = javaType> </#if> </#list> </#if> <#-- Helper function to convert Java types to Kotlin types --> <#function toKotlinType javaType> <#if javaType == "int" || javaType == "Integer" || javaType == "java.lang.Integer"> <#return "Int"> <#elseif javaType == "long" || javaType == "Long" || javaType == "java.lang.Long"> <#return "Long"> <#elseif javaType == "short" || javaType == "Short" || javaType == "java.lang.Short"> <#return "Short"> <#elseif javaType == "byte" || javaType == "Byte" || javaType == "java.lang.Byte"> <#return "Byte"> <#elseif javaType == "double" || javaType == "Double" || javaType == "java.lang.Double"> <#return "Double"> <#elseif javaType == "float" || javaType == "Float" || javaType == "java.lang.Float"> <#return "Float"> <#elseif javaType == "boolean" || javaType == "Boolean" || javaType == "java.lang.Boolean"> <#return "Boolean"> <#elseif javaType == "char" || javaType == "Character" || javaType == "java.lang.Character"> <#return "Char"> <#elseif javaType == "String" || javaType == "java.lang.String"> <#return "String"> <#elseif javaType == "BigDecimal" || javaType == "java.math.BigDecimal"> <#return "BigDecimal"> <#elseif javaType == "LocalDate" || javaType == "java.time.LocalDate"> <#return "LocalDate"> <#elseif javaType == "LocalDateTime" || javaType == "java.time.LocalDateTime"> <#return "LocalDateTime"> <#elseif javaType == "byte[]" || javaType == "Byte[]"> <#return "ByteArray"> <#elseif javaType == "int[]" || javaType == "Integer[]"> <#return "IntArray"> <#elseif javaType == "long[]" || javaType == "Long[]"> <#return "LongArray"> <#elseif javaType == "short[]" || javaType == "Short[]"> <#return "ShortArray"> <#elseif javaType == "double[]" || javaType == "Double[]"> <#return "DoubleArray"> <#elseif javaType == "float[]" || javaType == "Float[]"> <#return "FloatArray"> <#elseif javaType == "boolean[]" || javaType == "Boolean[]"> <#return "BooleanArray"> <#elseif javaType == "char[]" || javaType == "Character[]"> <#return "CharArray"> <#elseif javaType?starts_with("Set<")> <#return "MutableSet<" + javaType?substring(4)> <#elseif javaType?starts_with("java.util.Set<")> <#return "MutableSet<" + javaType?substring(14)> <#else> <#return javaType> </#if> </#function> /** * ${className} generated by Hibernate Tools */ <#if isCompositeKey> @Embeddable data class ${className}( <#list pojo.getAllPropertiesIterator() as property> <#assign propertyName = property.name> <#assign javaType = pojo.getJavaTypeName(property, true)> <#assign kotlinType = toKotlinType(javaType)> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> </#list> ) : Serializable { companion object { private const val serialVersionUID = 1L } } <#else> @Entity @Table(name = "${clazz.table.name}"<#if clazz.table.schema?? && clazz.table.schema?has_content>, schema = "${clazz.table.schema}"</#if>) class ${className}( <#list pojo.getAllPropertiesIterator() as property> <#assign propertyName = property.name> <#assign javaType = pojo.getJavaTypeName(property, true)> <#assign kotlinType = toKotlinType(javaType)> <#assign valueTypeName = property.value.class.simpleName> <#assign isId = pojo.hasIdentifierProperty() && pojo.getIdentifierProperty().name == propertyName> <#assign isEmbeddedId = propertyName == "id" && javaType?ends_with("Id")> <#if isEmbeddedId> @EmbeddedId var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> <#elseif isId> @Id @GeneratedValue(strategy = GenerationType.IDENTITY) <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}") <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}") </#if> </#if> var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> <#elseif valueTypeName == "ManyToOne"> @ManyToOne(fetch = FetchType.LAZY) <#assign columnName = ""> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> <#assign columnName = column.name> <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> <#assign columnName = column.name> </#if> </#if> <#if usesCompositeKey && columnName?has_content> <#assign mapsIdValue = columnName?lower_case?replace("_", " ")?capitalize?replace(" ", "")?uncap_first> @MapsId("${mapsIdValue}") </#if> <#if columnName?has_content> @JoinColumn(name = "${columnName}") </#if> var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> <#elseif valueTypeName == "Set" || valueTypeName == "Bag" || valueTypeName == "List"> <#assign mappedByValue = className?uncap_first> <#if propertyName?contains("For")> <#assign forIndex = propertyName?index_of("For")> <#if (forIndex > 0) && (forIndex < propertyName?length - 3)> <#assign columnSuffix = propertyName?substring(forIndex + 3)> <#assign mappedByValue = className?uncap_first + "By" + columnSuffix> </#if> </#if> @OneToMany(fetch = FetchType.LAZY, mappedBy = "${mappedByValue}") var ${propertyName}: ${kotlinType} = mutableSetOf()<#if property_has_next>,</#if> <#elseif valueTypeName == "BasicValue" || valueTypeName == "SimpleValue"> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if><#if (column.length)?? && column.length != 255 && (kotlinType == "String")>, length = ${column.length?c}</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> <#elseif valueTypeName == "Component"> @Embedded var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> <#else> // TODO: Unknown mapping type "${valueTypeName}" for property "${propertyName}" var ${propertyName}: ${kotlinType}? = null<#if property_has_next>,</#if> </#if> </#list> ) : Serializable { companion object { private const val serialVersionUID = 1L } } </#if>
Expand(187 more lines) resources/templates/hibernate/pojo/Pojo.ftl
<#-- Hibernate Tools 6.x Compatible Template for Groovy --> <#-- Note: This must be in templates/hibernate/pojo/Pojo.ftl --> ${pojo.getPackageDeclaration()} import groovy.transform.CompileStatic import groovy.transform.EqualsAndHashCode import groovy.transform.ToString import groovy.transform.builder.Builder import groovy.transform.builder.SimpleStrategy import jakarta.persistence.* import java.math.BigDecimal import java.time.LocalDate import java.time.LocalDateTime <#-- Determine if this is a composite key class (Embeddable) --> <#assign className = pojo.getDeclarationName()> <#assign isCompositeKey = !pojo.hasIdentifierProperty() && className?ends_with("Id")> <#-- For entities: check if they use a composite key --> <#assign usesCompositeKey = false> <#assign compositeKeyTypeName = ""> <#if !isCompositeKey> <#list pojo.getAllPropertiesIterator() as property> <#assign javaType = pojo.getJavaTypeName(property, true)> <#if property.name == "id" && javaType?ends_with("Id")> <#assign usesCompositeKey = true> <#assign compositeKeyTypeName = javaType> </#if> </#list> </#if> /** * ${className} generated by Hibernate Tools */ <#if isCompositeKey> @CompileStatic @Embeddable @Builder(builderStrategy = SimpleStrategy, prefix = '') @EqualsAndHashCode @ToString(includePackage = false) class ${className} implements Serializable { <#else> @CompileStatic @Entity @Table(name = "${clazz.table.name}"<#if clazz.table.schema?? && clazz.table.schema?has_content>, schema = "${clazz.table.schema}"</#if>) @Builder(builderStrategy = SimpleStrategy, prefix = '') @ToString(includePackage = false, includeNames = true) class ${className} implements Serializable { </#if> private static final long serialVersionUID = 1L <#-- Iterate over all properties --> <#list pojo.getAllPropertiesIterator() as property> <#assign propertyName = property.name> <#assign javaType = pojo.getJavaTypeName(property, true)> <#assign valueTypeName = property.value.class.simpleName> <#-- Check if this is the identifier --> <#assign isId = pojo.hasIdentifierProperty() && pojo.getIdentifierProperty().name == propertyName> <#-- Check if this is a composite/embedded id --> <#assign isEmbeddedId = propertyName == "id" && javaType?ends_with("Id")> <#if isCompositeKey> <#-- For composite key classes, just generate columns without @Id --> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> ${javaType} ${propertyName} <#else> <#-- Regular entity logic --> <#-- Handle EmbeddedId (composite primary key reference) --> <#if isEmbeddedId> @EmbeddedId ${javaType} ${propertyName} <#-- Generate @Id annotation for simple primary key --> <#elseif isId> @Id @GeneratedValue(strategy = GenerationType.IDENTITY) <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}") <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}") </#if> </#if> ${javaType} ${propertyName} <#-- Handle ManyToOne relationships --> <#elseif valueTypeName == "ManyToOne"> @ManyToOne(fetch = FetchType.LAZY) <#-- Determine column name for potential @MapsId --> <#assign columnName = ""> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> <#assign columnName = column.name> <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> <#assign columnName = column.name> </#if> </#if> <#-- If entity uses composite key, add @MapsId --> <#if usesCompositeKey && columnName?has_content> <#-- Convert ACTOR_ID -> actorId for @MapsId value --> <#assign mapsIdValue = columnName?lower_case?replace("_", " ")?capitalize?replace(" ", "")?uncap_first> @MapsId("${mapsIdValue}") </#if> <#if columnName?has_content> @JoinColumn(name = "${columnName}") </#if> ${javaType} ${propertyName} <#-- Handle OneToMany relationships (Set, Bag, List) --> <#elseif valueTypeName == "Set" || valueTypeName == "Bag" || valueTypeName == "List"> <#assign mappedByValue = className?uncap_first> <#if propertyName?contains("For")> <#assign forIndex = propertyName?index_of("For")> <#if (forIndex > 0) && (forIndex < propertyName?length - 3)> <#assign columnSuffix = propertyName?substring(forIndex + 3)> <#assign mappedByValue = className?uncap_first + "By" + columnSuffix> </#if> </#if> @OneToMany(fetch = FetchType.LAZY, mappedBy = "${mappedByValue}") Set<${javaType?replace("Set<", "")?replace(">", "")}> ${propertyName} = new HashSet<>(0) <#-- Handle regular columns (BasicValue, SimpleValue) --> <#elseif valueTypeName == "BasicValue" || valueTypeName == "SimpleValue"> <#if (property.value.columns)?? && property.value.columns?has_content> <#list property.value.columns as column> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if><#if (column.length)?? && column.length != 255 && javaType == "String">, length = ${column.length?c}</#if>) <#break> </#list> <#elseif (property.value.columnIterator)??> <#assign columnIterator = property.value.columnIterator> <#if columnIterator.hasNext()> <#assign column = columnIterator.next()> @Column(name = "${column.name}"<#if !column.nullable>, nullable = false</#if>) </#if> </#if> ${javaType} ${propertyName} <#-- Handle Component/Embedded types --> <#elseif valueTypeName == "Component"> @Embedded ${javaType} ${propertyName} <#-- Fallback for unknown types --> <#else> // TODO: Unknown mapping type "${valueTypeName}" for property "${propertyName}" ${javaType} ${propertyName} </#if> </#if> </#list> }
Expand(165 more lines)
Un template FreeMarker que controla la forma exacta de cada clase de entidad generada. Hibernate Tools lo llama una vez por tabla y pasa un objeto pojo describiendo las columnas y relaciones de la tabla. El template decide qué anotaciones emitir, cómo manejar claves compuestas, y cómo conectar relaciones @OneToMany y @ManyToOne.
Los tres variantes de lenguaje comparten la misma lógica estructural — detección de clave compuesta, manejo de @Id / @EmbeddedId / @ManyToOne / @OneToMany / @Column — pero difieren en qué emiten:
Java usa Lombok (@Getter, @Setter, @Builder, etc.) y campos private.
Kotlin tiene un helper FreeMarker toKotlinType() que convierte nombres de tipos Java a equivalentes Kotlin (Integer → Int, Set<X> → MutableSet<X>). Las clases de claves compuestas se vuelven data class para igualdad estructural; las entidades regulares son plain class para evitar problemas de proxying de JPA.
Groovy reemplaza Lombok por Groovy AST transforms (@CompileStatic, @Builder, @EqualsAndHashCode) y elimina la visibilidad explícita de campo ya que el mecanismo de propiedad de Groovy maneja eso.
Porque Hibernate Tools siempre emite archivos .java, las tareas de Kotlin y Groovy incluyen un paso post-generación que convierte .java a .kt y .java a .groovy respectivamente. La tarea de Java se saltea esto enteramente.
Paso 2 — Conectá build.gradle
build.gradle
configurations { hibernateTools } dependencies { def h2Version = '2.4.240' def hibernateVersion = '7.2.6.Final' hibernateTools "com.h2database:h2: ${ h2Version } " hibernateTools "org.hibernate.tool:hibernate-tools-ant: ${ hibernateVersion } " hibernateTools "org.hibernate.orm:hibernate-core: ${ hibernateVersion } " developmentOnly "com.h2database:h2: ${ h2Version } " testRuntimeOnly "com.h2database:h2: ${ h2Version } " developmentOnly 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' } tasks . register ( 'generateEntities' ) { group = 'build' description = 'Reverse engineers resources/sakila-schema.sql into JPA Entities' def outputDir = layout . buildDirectory . dir ( "generated/sources/hibernate" ) def sqlFile = layout . projectDirectory . file ( "src/main/resources/sakila-schema.sql" ) def revengFile = layout . projectDirectory . file ( "src/main/resources/hibernate.reveng.xml" ) def basePropsFile = layout . projectDirectory . file ( "src/main/resources/hibernate-tools.properties" ) def templateDir = layout . projectDirectory . dir ( "src/main/resources/templates/hibernate" ) inputs . file ( sqlFile ) inputs . file ( revengFile ) inputs . file ( basePropsFile ) . optional ( ) inputs . dir ( templateDir ) . optional ( ) outputs . dir ( outputDir ) doLast { def tempPropsFile = layout . buildDirectory . file ( "tmp/hibernate-tools.properties" ) . get ( ) . asFile tempPropsFile . parentFile . mkdirs ( ) def h2DbDir = layout . buildDirectory . dir ( "tmp" ) . get ( ) . asFile h2DbDir . mkdirs ( ) h2DbDir . listFiles ( ) ?. findAll { it . name . startsWith ( "sakila-h2" ) } ?. each { it . delete ( ) } def h2DbPath = layout . buildDirectory . file ( "tmp/sakila-h2" ) . get ( ) . asFile . absolutePath . replace ( '\\' , '/' ) def sqlPath = sqlFile . asFile . absolutePath . replace ( '\\' , '/' ) def h2Loader = new URLClassLoader ( configurations . hibernateTools . collect { it . toURI ( ) . toURL ( ) } as URL [ ] , ClassLoader . systemClassLoader ) def jdbcProps = new java . util . Properties ( ) jdbcProps . setProperty ( 'user' , 'sa' ) jdbcProps . setProperty ( 'password' , '' ) def h2Driver = h2Loader . loadClass ( 'org.h2.Driver' ) . getDeclaredConstructor ( ) . newInstance ( ) def initConn = h2Driver . connect ( "jdbc:h2:file: ${ h2DbPath } ;INIT=RUNSCRIPT FROM ' ${ sqlPath } '" , jdbcProps ) initConn . close ( ) h2Loader . close ( ) def props = new Properties ( ) if ( basePropsFile . asFile . exists ( ) ) { basePropsFile . asFile . withInputStream { props . load ( it ) } } props . setProperty ( 'hibernate.connection.url' , "jdbc:h2:file: ${ h2DbPath } " ) tempPropsFile . withOutputStream { props . store ( it , null ) } def destDir = outputDir . get ( ) . asFile destDir . mkdirs ( ) ant . taskdef ( name : 'hibernatetool' , classname : 'org.hibernate.tool.ant.HibernateToolTask' , classpath : configurations . hibernateTools . asPath ) ant . hibernatetool ( destdir : destDir , templatepath : templateDir . asFile ) { jdbcconfiguration ( propertyfile : tempPropsFile , revengfile : revengFile . asFile , packagename : " ${ project . group } . ${ project . name } .generated.entity" , detectmanytomany : true , detectoptimisticlock : true ) hbm2java ( jdk5 : true , ejb3 : true ) } } } sourceSets { main { java { srcDir ( layout . buildDirectory . dir ( "generated/sources/hibernate" ) ) } } } tasks . named ( 'compileJava' ) { dependsOn 'generateEntities' }
Expand(100 more lines) build.gradle.kts
import java . net . URLClassLoader import java . util . Properties plugins { kotlin ( "plugin.jpa" ) version "2.2.21" } val hibernateTools : Configuration by configurations . creating dependencies { val hibernateVersion = "7.2.6.Final" val h2Version = "2.4.240" hibernateTools ( "com.h2database:h2: $ h2Version " ) hibernateTools ( "org.hibernate.tool:hibernate-tools-ant: $ hibernateVersion " ) hibernateTools ( "org.hibernate.orm:hibernate-core: $ hibernateVersion " ) developmentOnly ( "com.h2database:h2: $ h2Version " ) testRuntimeOnly ( "com.h2database:h2: $ h2Version " ) developmentOnly ( "org.springframework.boot:spring-boot-h2console" ) implementation ( "org.springframework.boot:spring-boot-starter-data-jpa" ) testImplementation ( "org.springframework.boot:spring-boot-starter-data-jpa-test" ) } val hibernateGeneratedSourcesDir = layout . buildDirectory . dir ( "generated/sources/hibernate" ) tasks . register ( "generateEntities" ) { group = "build" description = "Reverse engineers resources/sakila.sql into JPA Entities (Kotlin)" val sqlFile = file ( "src/main/resources/sakila-schema.sql" ) val revengFile = file ( "src/main/resources/hibernate.reveng.xml" ) val basePropsFile = file ( "src/main/resources/hibernate-tools.properties" ) val templateDir = file ( "src/main/resources/templates/hibernate" ) inputs . file ( sqlFile ) inputs . file ( revengFile ) inputs . file ( basePropsFile ) . optional ( ) inputs . dir ( templateDir ) . optional ( ) outputs . dir ( hibernateGeneratedSourcesDir ) doLast { val tempPropsFile = layout . buildDirectory . file ( "tmp/hibernate-tools.properties" ) . get ( ) . asFile tempPropsFile . parentFile . mkdirs ( ) val h2DbDir = layout . buildDirectory . dir ( "tmp" ) . get ( ) . asFile h2DbDir . mkdirs ( ) h2DbDir . listFiles ( ) ? . filter { it . name . startsWith ( "sakila-h2" ) } ? . forEach { it . delete ( ) } val h2DbPath = layout . buildDirectory . file ( "tmp/sakila-h2" ) . get ( ) . asFile . absolutePath . replace ( "\\" , "/" ) val sqlPath = sqlFile . absolutePath . replace ( "\\" , "/" ) val h2Loader = URLClassLoader ( configurations . getByName ( "hibernateTools" ) . map { it . toURI ( ) . toURL ( ) } . toTypedArray ( ) , ClassLoader . getSystemClassLoader ( ) , ) val jdbcProps = Properties ( ) jdbcProps . setProperty ( "user" , "sa" ) jdbcProps . setProperty ( "password" , "" ) val h2Driver = h2Loader . loadClass ( "org.h2.Driver" ) . getDeclaredConstructor ( ) . newInstance ( ) val initConn = ( h2Driver as java . sql . Driver ) . connect ( "jdbc:h2:file: ${ h2DbPath } ;INIT=RUNSCRIPT FROM ' ${ sqlPath } '" , jdbcProps , ) initConn !! . close ( ) h2Loader . close ( ) val props = Properties ( ) if ( basePropsFile . exists ( ) ) { basePropsFile . inputStream ( ) . use { stream -> props . load ( stream ) } } props . setProperty ( "hibernate.connection.url" , "jdbc:h2:file: ${ h2DbPath } " ) tempPropsFile . outputStream ( ) . use { stream -> props . store ( stream , null ) } val destDir = hibernateGeneratedSourcesDir . get ( ) . asFile destDir . mkdirs ( ) ant . withGroovyBuilder { "taskdef" ( "name" to "hibernatetool" , "classname" to "org.hibernate.tool.ant.HibernateToolTask" , "classpath" to hibernateTools . asPath , ) "hibernatetool" ( "destdir" to destDir , "templatepath" to templateDir , ) { "jdbcconfiguration" ( "propertyfile" to tempPropsFile , "revengfile" to revengFile , "packagename" to " ${ project . group } . ${ project . name } .generated.entity" , "detectmanytomany" to true , "detectoptimisticlock" to true , ) "hbm2java" ( "jdk5" to true , "ejb3" to true ) } } File ( destDir , " ${ project . group } . ${ project . name } .generated.entity" . replace ( '.' , '/' ) ) . listFiles ( ) ? . filter { it . extension == "java" } ? . forEach { javaFile -> val ktFile = File ( javaFile . parentFile , " ${ javaFile . nameWithoutExtension } .kt" ) javaFile . renameTo ( ktFile ) println ( "Renamed: ${ javaFile . name } -> ${ ktFile . name } " ) } } } kotlin . sourceSets [ "main" ] . kotlin { srcDir ( hibernateGeneratedSourcesDir ) } tasks . named ( "compileKotlin" ) { dependsOn ( "generateEntities" ) } tasks . withType < org . jetbrains . kotlin . gradle . internal . KaptGenerateStubsTask > { dependsOn ( "generateEntities" ) }
Expand(126 more lines) build.gradle
configurations { hibernateTools } dependencies { def h2Version = '2.4.240' def hibernateVersion = '7.2.6.Final' hibernateTools "com.h2database:h2: ${ h2Version } " hibernateTools "org.hibernate.tool:hibernate-tools-ant: ${ hibernateVersion } " hibernateTools "org.hibernate.orm:hibernate-core: ${ hibernateVersion } " developmentOnly "com.h2database:h2: ${ h2Version } " testRuntimeOnly "com.h2database:h2: ${ h2Version } " developmentOnly 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' } tasks . register ( 'generateEntities' ) { group = 'build' description = 'Reverse engineers resources/sakila.sql into Groovy JPA Entities' def outputDir = layout . buildDirectory . dir ( "generated/sources/hibernate" ) def sqlFile = layout . projectDirectory . file ( "src/main/resources/sakila-schema.sql" ) def revengFile = layout . projectDirectory . file ( "src/main/resources/hibernate.reveng.xml" ) def basePropsFile = layout . projectDirectory . file ( "src/main/resources/hibernate-tools.properties" ) def templateDir = layout . projectDirectory . dir ( "src/main/resources/templates/hibernate" ) inputs . file ( sqlFile ) inputs . file ( revengFile ) inputs . file ( basePropsFile ) . optional ( ) inputs . dir ( templateDir ) . optional ( ) outputs . dir ( outputDir ) doLast { def tempPropsFile = layout . buildDirectory . file ( "tmp/hibernate-tools.properties" ) . get ( ) . asFile tempPropsFile . parentFile . mkdirs ( ) def h2DbDir = layout . buildDirectory . dir ( "tmp" ) . get ( ) . asFile h2DbDir . mkdirs ( ) h2DbDir . listFiles ( ) ?. findAll { it . name . startsWith ( "sakila-h2" ) } ?. each { it . delete ( ) } def h2DbPath = layout . buildDirectory . file ( "tmp/sakila-h2" ) . get ( ) . asFile . absolutePath . replace ( '\\' , '/' ) def sqlPath = sqlFile . asFile . absolutePath . replace ( '\\' , '/' ) def h2Loader = new URLClassLoader ( configurations . hibernateTools . collect { it . toURI ( ) . toURL ( ) } as URL [ ] , ClassLoader . systemClassLoader ) def jdbcProps = new java . util . Properties ( ) jdbcProps . setProperty ( 'user' , 'sa' ) jdbcProps . setProperty ( 'password' , '' ) def h2Driver = h2Loader . loadClass ( 'org.h2.Driver' ) . getDeclaredConstructor ( ) . newInstance ( ) def initConn = h2Driver . connect ( "jdbc:h2:file: ${ h2DbPath } ;INIT=RUNSCRIPT FROM ' ${ sqlPath } '" , jdbcProps ) initConn . close ( ) h2Loader . close ( ) def props = new Properties ( ) if ( basePropsFile . asFile . exists ( ) ) { basePropsFile . asFile . withInputStream { props . load ( it ) } } props . setProperty ( 'hibernate.connection.url' , "jdbc:h2:file: ${ h2DbPath } " ) tempPropsFile . withOutputStream { props . store ( it , null ) } def destDir = outputDir . get ( ) . asFile destDir . mkdirs ( ) ant . taskdef ( name : 'hibernatetool' , classname : 'org.hibernate.tool.ant.HibernateToolTask' , classpath : configurations . hibernateTools . asPath ) ant . hibernatetool ( destdir : destDir , templatepath : templateDir . asFile ) { jdbcconfiguration ( propertyfile : tempPropsFile , revengfile : revengFile . asFile , packagename : " ${ project . group } . ${ project . name } .generated.entity" , detectmanytomany : true , detectoptimisticlock : true ) hbm2java ( jdk5 : true , ejb3 : true ) } def entityDir = new File ( destDir , " ${ project . group } . ${ project . name } .generated.entity" . replace ( '.' , '/' ) ) if ( entityDir . exists ( ) ) { entityDir . eachFileMatch ( ~ /.*.java/ ) { javaFile -> def groovyFile = new File ( javaFile . parentFile , javaFile . name . replace ( '.java' , '.groovy' ) ) javaFile . renameTo ( groovyFile ) println "Renamed: ${ javaFile . name } -> ${ groovyFile . name } " } } } } sourceSets { main { groovy { srcDir ( layout . buildDirectory . dir ( "generated/sources/hibernate" ) ) } } } tasks . named ( 'compileGroovy' ) { dependsOn 'generateEntities' }
Expand(112 more lines)
Hay algunas cosas que vale la pena destacar en el diff:
Configuración hibernateTools es un bucket de dependencias Gradle separado que existe solo para generación de código. Contiene el Hibernate Tools Ant runner, Hibernate ORM core, y H2, ninguno de los cuales pertenece en el classpath de compilación o runtime de tu aplicación. Mantenerlos acá significa que hacen su trabajo en tiempo de build y desaparecen.
Dependencias de runtime y compilación agregan los jars estándares de Spring Data JPA y H2 que tu aplicación necesita en runtime. La variante Kotlin también requiere el plugin Gradle kotlin("plugin.jpa"), que genera los constructores no-arg que JPA necesita internamente, algo que las clases Kotlin no proveen por defecto.
Tarea generateEntities hace el trabajo en tres pasos:
Lee hibernate-tools.properties, agrega una hibernate.connection.url dinámica que apunta H2 al archivo de esquema SQL vía INIT=RUNSCRIPT FROM '...', y escribe las propiedades combinadas a un archivo temporal.
Llama a la tarea Ant HibernateToolTask, pasando ese archivo temporal de propiedades, hibernate.reveng.xml, el directorio de salida, y el directorio de templates FreeMarker. Hibernate Tools hace el resto.
Renombra los archivos .java generados a .kt o .groovy donde sea necesario (solo Kotlin y Groovy).
Registro de source set agrega build/generated/sources/hibernate/ al source set apropiado para que el compilador lo detecte. Kotlin conecta generateEntities antes de compileKotlin y la tarea de generación de kapt stub, ya que el procesamiento de anotaciones corre incluso más temprano y también necesita los tipos generados disponibles.
Qué se Genera
Después de correr ./gradlew generateEntities (o cualquier tarea que dependa de ella, como build), encontrarás archivos fuente de entidades bajo:
build/generated/sources/hibernate/<group>/<name>/generated/entity/
Cada tabla en el esquema se vuelve una clase de entidad, compilada transparentemente junto a tu código escrito a mano. No referencías este directorio manualmente. El registro del source set se encarga de eso.
Sobre los comentarios // TODO: Unknown mapping type "EnhancedBasicValue"
Es probable que veas comentarios como este esparcidos por los archivos generados:
private String firstName ;
EnhancedBasicValue es un tipo interno de Hibernate ORM 7.x que reemplazó al antiguo BasicValue. El template FreeMarker de hibernate-tools-ant que genera el código fuente Java todavía no lo reconoce. Es una brecha de compatibilidad entre la versión de las herramientas y la del ORM. Cuando el template encuentra un tipo de mapeo no reconocido, se limita a emitir el campo sin anotaciones y deja un comentario TODO en lugar de generar la anotación @Column(name = "FIRST_NAME") completa.
Por qué las pruebas siguen pasando
El SpringPhysicalNamingStrategy por defecto de Spring Boot convierte automáticamente los nombres de campo camelCase a snake_case en los nombres de columna: firstName → first_name. H2 es case-insensitive y PostgreSQL almacena nombres de columna en minúsculas por defecto, así que ambos coinciden con lo que produce la estrategia. Las anotaciones @Column faltantes no generan ningún desajuste.
Cuándo se convertiría en un problema
Si un nombre de columna no sigue la convención camelCase → snake_case.
Si necesitás anotaciones @Column explícitas para documentación o para sobreescribir la estrategia de nombres.
Para un proyecto de demo que usa la estrategia de nombres por defecto contra Sakila (que sigue snake_case consistentemente), las entidades generadas funcionan correctamente tal cual están. Los TODOs son ruido de una incompatibilidad conocida entre hibernate-tools y Hibernate 7.x. Podés ignorarlos.
Cambios de Esquema en el Mundo Real
Cómo Spring habla con las bases de datos cubre enfoques code-first vs. database-first en detalle. La versión corta: code-first es cómodo para desarrollo local, database-first (Flyway, Liquibase) es más seguro para producción.
Con ingeniería inversa, el proceso se acerca más al enfoque database-first — pero el paso de "ponerse al día" es automatizado. Supongamos que producción necesita una columna nueva, last_updated en la tabla film. Actualizás sakila-schema.sql para incluir la columna nueva, entonces corrés ./gradlew generateEntities (o solo build). La tarea re-introspecciona el esquema y regenera todos los archivos de entidades. El nuevo campo aparece automáticamente en la entidad — no lo tocás a mano.
La contrapartida es que vos sos el dueño del archivo SQL. Cuando la base de datos real cambia, necesitás mantener sakila-schema.sql sincronizado, sino las entidades generadas se desvían de la realidad. En la práctica esto significa que tu migración de esquema (Flyway, Liquibase, o un script SQL plano) y la actualización a tu archivo SQL local deberían pasar juntos, idealmente en el mismo commit.
Visión Honesta: Probablemente No Verás Esto en la Naturaleza
En todos los codebases reales que se cruzan en el camino, las entidades fueron escritas a mano. El enfoque de ingeniería inversa apareció exactamente una vez en un proyecto de producción.
La mayoría de equipos o comenzaron su proyecto desde cero (esquema y entidades crecen juntos) o heredaron un codebase legacy donde alguien ya escribió las entidades años atrás. El caso de uso para generarlas desde SQL en tiempo de build es genuinamente acotado: tenés un esquema existente que no controlás, necesitás entidades JPA rápido, y no querés escribir cientos de campos a mano.
Entonces mantené esto en tu toolkit. Entendé qué hace el tooling y cómo se configura. Solo no te sorprendas si pasás una carrera entera y nunca lo encontrás en un codebase.