Saltar al contenido principal
Versión: v0.1.3

core-persistence

Qué problema resuelve​

La mayoría de los proyectos nuevos de EchoTechs corren sobre Postgres, pero varios clientes existentes corren sobre Oracle. Cada deployment usa un solo motor — el problema no es sincronizar dos bases, sino que el mismo código y las mismas migraciones funcionen idénticamente en ambos sin que el código de dominio sepa cuál está debajo.

core-persistence es la pieza más pequeña del core y también la más transversal: aporta una única clase (CoreEntity) y un único changeset de Liquibase (la creación del esquema core), que todos los demás módulos con tablas propias heredan e incluyen. Su valor está en las decisiones de portabilidad que fija: si toda entidad del core usa el mismo tipo de id y el mismo mapeo de columnas, no hay divergencia posible entre motores.

Cómo se agrega​

Normalmente no hace falta declararlo: core-auth, core-storage, core-workflow y core-config ya lo exponen como api(...). Declaralo explícitamente sólo si tus propias entidades extienden CoreEntity sin depender de ningún otro módulo del core.

build.gradle.kts
dependencies {
implementation("dev.echotechs.core:core-persistence:0.1.0-SNAPSHOT")
}

Trae transitivamente (todas como api): spring-boot-starter-data-jpa, liquibase-core, spring-boot-liquibase, spring-boot-autoconfigure y spring-boot-persistence.

@EntityScan vive en su propio artefacto en Boot 4.1

org.springframework.boot.persistence.autoconfigure.EntityScan — no el paquete autoconfigure.domain de Boot 3.x. Por eso spring-boot-persistence es una dependencia explícita del módulo.

API pública​

CoreEntity​

dev.echotechs.core.persistence.CoreEntity — base de toda entidad del core.

@MappedSuperclass
public abstract class CoreEntity {

@Id
@UuidGenerator
@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "id", updatable = false, nullable = false, length = 36)
private UUID id;

public UUID getId();

@Override public boolean equals(Object o);
@Override public int hashCode();
}

Era la única clase pública del módulo hasta la convención de catálogos (spec 1.2, ver más abajo) — ahora comparte el módulo con dev.echotechs.core.persistence.catalog.*. Tres decisiones vienen empaquetadas en CoreEntity:

  1. @UuidGenerator — el id es un UUID v4 generado en la aplicación, no por la base. Un UUID aleatorio no necesita ninguna estrategia de generación específica del vendor, así que Postgres y Oracle se comportan idénticamente con cero configuración divergente.
  2. @JdbcTypeCode(SqlTypes.CHAR) — fuerza la columna a la forma canónica de 36 caracteres, en vez del default dependiente del dialecto (uuid nativo en Postgres, RAW(16) binario en Oracle). Una sola forma de columna, DDL idéntico en ambos motores.
  3. equals/hashCode seguros para proxies — equals compara vía Hibernate.getClass(...) (no getClass()) para que una entidad y su proxy lazy se consideren iguales; hashCode es constante por clase, que es lo correcto cuando el id se asigna recién al persistir.
hashCode() es constante por clase

Objects.hashCode(getClass().hashCode()) devuelve el mismo valor para todas las instancias del mismo tipo. Es correcto (una entidad no cambia de hash al recibir su id), pero significa que un HashSet grande de entidades del mismo tipo degrada a búsqueda lineal. No metas miles de entidades en un HashSet.

Convención de catálogos (spec 1.2) — dev.echotechs.core.persistence.catalog​

Verificado end-to-end contra Postgres y Oracle reales (Testcontainers), no sólo compilado — la combinación @SoftDelete + bulk delete tenía un comportamiento real que un test superficial no habría agarrado (ver la nota debajo).

public interface Activatable {
boolean isActive();
void activate();
void deactivate();
}

Sin ninguna relación con soft/hard delete — activo/inactivo es una decisión de negocio ("¿se sigue ofreciendo para uso nuevo?"), no una decisión técnica de si la fila existe.

@MappedSuperclass
@SoftDelete(columnName = "deleted")
public abstract class SoftDeletableEntity extends CoreEntity {}

@SoftDelete es nativo de Hibernate (6.4+; este repo corre 7.4.1.Final vía el BOM de Boot 4.1) — convierte cualquier delete()/remove() en un UPDATE, y excluye filas marcadas de toda query futura contra esa entidad, automáticamente.

@NoRepositoryBean
public interface CatalogRepository<T> extends JpaRepository<T, UUID>, JpaSpecificationExecutor<T> {
void purge(UUID id);
}
purge() no tiene implementación genérica — y no puede tenerla

El diseño original (calcado del spec) era @Query("DELETE FROM #{#entityName} e WHERE e.id = :id"). Roto en la práctica: el filtro de @SoftDelete se aplica también a DELETE/UPDATE en bulk JPQL, no sólo a SELECT — así que ese DELETE no encuentra nunca la fila que ya está soft-deleted (que es exactamente el caso de uso real de purge: vaciar la papelera). Verificado con un test que primero soft-borra y después llama purge() — contaba 1 fila física después del "hard delete" hasta que se cambió el enfoque.

La única forma real de bypasear el filtro es SQL nativo contra la tabla física, y eso necesita el nombre de tabla real — no hay forma 100% genérica. Cada repositorio concreto implementa su propio purge():

public interface UserAccountRepository extends CatalogRepository<UserAccount> {
@Override
@Modifying
@Query(value = "DELETE FROM core.user_account WHERE id = :#{#id.toString()}", nativeQuery = true)
void purge(UUID id);
}

El :#{#id.toString()} (SpEL) también es necesario: una query nativa no aplica la conversión @JdbcTypeCode(CHAR) de la entidad, así que un UUID pasado directo se manda como el tipo uuid nativo de Postgres y falla contra una columna CHAR ("operator does not exist: character = uuid").

Requiere una transacción activa en el punto de llamada (es una query @Modifying) — a diferencia de save()/delete(), que Spring Data envuelve en su propia transacción implícita por método, @Modifying/@Query no lo hace. Llamalo desde un método de servicio @Transactional.

public record CatalogFilters(String text, Boolean active) {}

public abstract class CatalogServiceBase<T> {
public Page<T> list(CatalogFilters filters, Pageable pageable);
protected Specification<T> activeFilter(Boolean active); // default: sin filtro
protected Specification<T> textSearch(String text); // default: sin filtro
protected Specification<T> customFilters(CatalogFilters filters); // default: sin filtro
}
Specification.and(null) no es un no-op en esta versión

La cadena clásica Specification.where(spec).and(other) asume que .and(null) se ignora — en Spring Data JPA 4.1 lanza IllegalArgumentException: Other specification must not be null. CatalogServiceBase.list guarda explícitamente contra null antes de combinar, en vez de encadenar directo.

UserAccount (core-auth, ver su propia página) es el primer consumidor real: implementa Activatable (desactivar, no borrar) sin @SoftDelete (el spec pide desactivar ahí, no papelera).

El changeset del esquema core​

classpath:db/changelog/core-persistence/changelog-master.xml crea el esquema core:

<changeSet id="core-persistence-001-create-schema" author="platform-core" dbms="postgresql,h2">
<sql>CREATE SCHEMA IF NOT EXISTS core;</sql>
</changeSet>

Incluilo vos mismo, una sola vez, primero en el changelog master de tu proyecto — antes de los changelogs de core-auth/core-storage/core-workflow/core-config que uses:

db/changelog/app/changelog-master.xml
<include file="db/changelog/core-persistence/changelog-master.xml"/>
<include file="db/changelog/core-auth/changelog-master.xml"/>
<include file="db/changelog/core-storage/changelog-master.xml"/>
<!-- tus propios changesets debajo -->
Ninguno de los módulos del core se auto-incluye a sí mismo

Ninguno de core-auth/core-storage/core-workflow/core-config incluye este changelog desde su propio changelog-master.xml — y es a propósito. Liquibase deduplica changesets repetidos entre corridas (uno ya ejecutado no se repite), pero no dentro de un mismo parseo: si cada módulo se auto-incluyera y tu proyecto combinara más de uno, Liquibase vería el mismo id+author+file cuatro veces en un solo árbol y fallaría con ValidationFailedException: duplicate identifiers — exactamente lo que pasó al armar reference-app, que combina los cuatro. La responsabilidad de incluir este changelog es de tu proyecto, una sola vez.

Fijate en dbms="postgresql,h2": en Oracle no corre. Ahí un esquema es un usuario de base de datos, y crearlo es un paso de infraestructura (credenciales, cuotas de tablespace), no algo que una migración portable deba hacer. Aprovisioná ese esquema fuera de banda antes de correr el changelog contra Oracle.

Ejemplo de uso​

Adaptado de core-persistence/src/test/java/.../PortabilityProbe.java y su changeset, que es la entidad que la matriz de CI usa para probar portabilidad:

Tu entidad de dominio
package com.tuproyecto.purchase;

import dev.echotechs.core.persistence.CoreEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;

@Entity
@Table(name = "purchase_request", schema = "app")
public class PurchaseRequest extends CoreEntity {

@Column(name = "title", nullable = false, length = 255)
private String title;

@Lob
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;

protected PurchaseRequest() {
}

public PurchaseRequest(String title, String notes) {
this.title = title;
this.notes = notes;
}

public String getTitle() {
return title;
}

public String getNotes() {
return notes;
}
}

El changeset correspondiente — fijate en CHAR(36) para el id, que es lo que @JdbcTypeCode(SqlTypes.CHAR) espera:

db/changelog/app/001-purchase-request.xml
<changeSet id="app-001-purchase-request" author="tuproyecto">
<createTable tableName="purchase_request" schemaName="app">
<column name="id" type="CHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="title" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
<column name="notes" type="TEXT"/>
</createTable>
</changeSet>

Y el repositorio, sin nada especial:

public interface PurchaseRequestRepository extends JpaRepository<PurchaseRequest, UUID> {
}

Probar portabilidad en tu propio proyecto​

El patrón que usa el módulo: una clase abstracta con las aserciones, y una subclase por motor que sólo aporta el contenedor. Extraído de AbstractPortabilityIntegrationTest / PostgresPortabilityIntegrationTest:

@Testcontainers
class PostgresPortabilityIntegrationTest extends AbstractPortabilityIntegrationTest {

@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");

@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.liquibase.change-log",
() -> "classpath:db/changelog/core-persistence-test/changelog-master.xml");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
}

Y la variante Oracle, con dos detalles que no son obvios:

@Container
static final OracleContainer ORACLE = new OracleContainer("gvenzl/oracle-free:slim-faststart")
.withUsername("core")
.withPassword("CoreTest123")
.withStartupTimeout(Duration.ofMinutes(5));
  • withUsername("core") hace que el esquema default del usuario satisfaga @Table(schema = "core") sin ningún aprovisionamiento extra — en Oracle un esquema es un usuario.
  • El timeout default de OracleContainer es de 60 segundos, suficiente cuando la imagen (~2 GB) ya está cacheada, pero no para un pull en frío en un runner de CI nuevo.

Las aserciones que corren contra ambos motores cubren: que el id se genere como UUID en forma canónica, que una columna TEXT acepte 5000 caracteres, y que Pageable produzca páginas consistentes.

Errores comunes​

Schema-validation: missing table [core.xxx] al arrancar contra Oracle. El changeset del esquema core no corre en Oracle (dbms="postgresql,h2"). Aprovisioná el usuario/esquema fuera de banda antes del primer despliegue, o conectá con un usuario cuyo esquema default sea core.

Un String mapea a VARCHAR(255) y falla contra una columna TEXT. Un campo String sin más se mapea a VARCHAR(255), y ddl-auto: validate lo rechaza contra una columna TEXT/CLOB. Necesitás @Lob y columnDefinition:

@Lob
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;

spring.jpa.hibernate.ddl-auto en algo distinto de validate. Todos los application.yml de test del core usan validate. Si dejás que Hibernate genere el DDL, el esquema real deja de ser el que describen los changesets y la matriz de portabilidad ya no prueba nada.

Las entidades del core no aparecen. Cada módulo del core declara su propio @EntityScan/@EnableJpaRepositories apuntando a sus paquetes, porque viven fuera del árbol de paquetes de tu aplicación. Si escribiste tu propio @EntityScan global, asegurate de no estar sobrescribiendo esa configuración.

Notas de implementación​

Diferencias entre platform-core-spec.md y lo que hace el código:

Generación de id: el spec pide secuencias, el código usa UUID aleatorio. La sección 1.2 del spec dice: "Generación de ID por secuencia (SequenceStyleGenerator), no IDENTITY". El código usa @UuidGenerator (v4, generado en la aplicación), sin secuencia ni IDENTITY. El Javadoc de CoreEntity documenta el cambio como deliberado: un UUID aleatorio no necesita ninguna estrategia de generación específica del vendor, lo cual cumple el objetivo de portabilidad del spec de forma más directa que una secuencia. Es una divergencia intencional y documentada, no un descuido.

Separación de esquemas: el spec describe core + app, el código sólo crea core. El spec (1.2) propone dos esquemas y, en Oracle, dos usuarios (CORE_OWNER, APP_OWNER) con grants cruzados. El código sólo crea core, y sólo en Postgres/H2. El esquema app y todo el aprovisionamiento de Oracle quedan como responsabilidad del proyecto de dominio / DBA.

No hay columnas de auditoría en CoreEntity. CoreEntity aporta únicamente el id. No hay createdAt/updatedAt/createdBy heredados: cada entidad del core que los necesita los declara por su cuenta (UserSession.createdAt, ApiKey.createdAt, ConfigEntry.createdAt/updatedAt, etc.). El spec no los pedía explícitamente, pero conviene saberlo antes de asumir que extender CoreEntity te da auditoría.

@Lob + columnDefinition = "TEXT" es la convención real para texto largo. El spec dice "se guarda como TEXT/CLOB con serialización a nivel de aplicación", sin precisar el mapeo. En el código el patrón consistente es @Lob junto con columnDefinition = "TEXT" — está así en ApiKey.scopes, ServiceAccountClient.scopes, WorkflowTransition.reason, ConfigEntry.value y PortabilityProbe.notes.