Migrate hibernate from 6.1.7.Final to 7.1.1.Final

Change-Id: Ib5855c283ce6c65401009db8b42e4aae9afa3669
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index eecc454..4cd1904 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -9,9 +9,4 @@
     directory: "/" # Location of package manifests
     schedule:
       interval: "daily"
-    ignore:
-      # Hibernate >=6.2 is incompatible
-      - dependency-name: "org.hibernate.orm:hibernate-*"
-        versions:
-          - ">= 6.2.0"
     open-pull-requests-limit: 50
diff --git a/pom.xml b/pom.xml
index 31d98b4..2a7f13a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,8 +9,7 @@
 		<java.version>17</java.version>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 		<jersey.version>3.1.11</jersey.version>
-		<hibernate.ehcache.version>6.0.0.Alpha7</hibernate.ehcache.version>
-		<hibernate.version>6.1.7.Final</hibernate.version>
+		<hibernate.version>7.1.1.Final</hibernate.version>
 		<spring.version>6.2.11</spring.version>
 		<!-- spring6.version is used in jersey and defined here 
 		to make sure that jersey uses the correct spring version-->
@@ -523,17 +522,7 @@
 			<artifactId>hibernate-jpamodelgen</artifactId>
 			<version>${hibernate.version}</version>
 		</dependency>
-		<dependency>
-			<groupId>org.hibernate.orm</groupId>
-			<artifactId>hibernate-ehcache</artifactId>
-			<version>${hibernate.ehcache.version}</version>
-			<exclusions>
-				<exclusion>
-					<groupId>org.hibernate</groupId>
-					<artifactId>hibernate-core</artifactId>
-				</exclusion>
-			</exclusions>
-		</dependency>
+		<!-- Remove hibernate-ehcache (Ehcache 2 integration removed in modern Hibernate) -->
 		<dependency>
 			<groupId>org.hibernate.orm</groupId>
 			<artifactId>hibernate-c3p0</artifactId>
@@ -586,6 +575,11 @@
 
 		<!-- Utilities -->
 		<dependency>
+			<groupId>net.sf.ehcache</groupId>
+			<artifactId>ehcache</artifactId>
+			<version>2.10.6</version>
+		</dependency>
+		<dependency>
 			<groupId>joda-time</groupId>
 			<artifactId>joda-time</artifactId>
 			<version>2.14.0</version>
diff --git a/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java b/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java
index 6c68c13..b2c09cf 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java
@@ -165,7 +165,36 @@
         return new HashSet<Role>(resultList);
     }
 
-    public Role retrieveRoleByGroupIdQueryIdPrivilege (int groupId, int queryId,
+    /**
+     * Retrieve all roles associated with a given query id, including their members.
+     */
+    public List<Role> retrieveRolesByQueryIdWithMembers(int queryId) {
+        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+        CriteriaQuery<Role> cq = cb.createQuery(Role.class);
+
+        Root<Role> role = cq.from(Role.class);
+        role.fetch(Role_.userGroupMembers, JoinType.LEFT);
+        role.fetch(Role_.userGroup, JoinType.INNER);
+        // query is optional for some roles, but we filter roles linked to the query
+        cq.select(role);
+        cq.where(cb.equal(role.get(Role_.query).get(QueryDO_.id), queryId));
+
+        TypedQuery<Role> q = entityManager.createQuery(cq);
+        return q.getResultList();
+    }
+
+    /**
+     * Bulk delete all roles associated with a given query id.
+     */
+    public void deleteRolesByQueryId(int queryId) {
+        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+        CriteriaDelete<Role> delete = cb.createCriteriaDelete(Role.class);
+        Root<Role> role = delete.from(Role.class);
+        delete.where(cb.equal(role.get(Role_.query).get(QueryDO_.id), queryId));
+        entityManager.createQuery(delete).executeUpdate();
+    }
+
+    public Role retrieveRoleByGroupIdQueryIdPrivilege(int groupId, int queryId,
             PrivilegeType p) throws KustvaktException {
 
         CriteriaBuilder cb = entityManager.getCriteriaBuilder();
diff --git a/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java b/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java
index 2545122..8627f1d 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java
@@ -111,7 +111,23 @@
                     "groupId: " + groupId);
         }
 
-        // EM: this seems weird
+        // Before deleting the group, detach role associations from members to
+        // avoid transient role references during flush
+        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+        CriteriaQuery<UserGroupMember> cq = cb.createQuery(UserGroupMember.class);
+        Root<UserGroupMember> memberRoot = cq.from(UserGroupMember.class);
+        cq.select(memberRoot);
+        cq.where(cb.equal(memberRoot.get(UserGroupMember_.group).get("id"), groupId));
+        @SuppressWarnings("unchecked")
+        List<UserGroupMember> members = entityManager.createQuery(cq).getResultList();
+        for (UserGroupMember m : members) {
+            if (!entityManager.contains(m)) {
+                m = entityManager.merge(m);
+            }
+            m.setRoles(new HashSet<>());
+            entityManager.merge(m);
+        }
+
         if (!entityManager.contains(group)) {
             group = entityManager.merge(group);
         }
diff --git a/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java b/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
index 86ab9dd..e2da736 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
@@ -42,12 +42,38 @@
 
     public void addMember (UserGroupMember member) throws KustvaktException {
         ParameterChecker.checkObjectValue(member, "userGroupMember");
+        if (member.getRoles() != null) {
+            java.util.Set<Role> normalized = new java.util.HashSet<Role>();
+            for (Role role : member.getRoles()) {
+                if (role.getId() == 0) {
+                    entityManager.persist(role);
+                    normalized.add(role);
+                } else {
+                    Role attached = entityManager.contains(role) ? role : entityManager.merge(role);
+                    normalized.add(attached);
+                }
+            }
+            member.setRoles(normalized);
+        }
         entityManager.persist(member);
         entityManager.flush();
     }
 
     public void updateMember (UserGroupMember member) throws KustvaktException {
         ParameterChecker.checkObjectValue(member, "UserGroupMember");
+        if (member.getRoles() != null) {
+            java.util.Set<Role> normalized = new java.util.HashSet<Role>();
+            for (Role role : member.getRoles()) {
+                if (role.getId() == 0) {
+                    entityManager.persist(role);
+                    normalized.add(role);
+                } else {
+                    Role attached = entityManager.contains(role) ? role : entityManager.merge(role);
+                    normalized.add(attached);
+                }
+            }
+            member.setRoles(normalized);
+        }
         entityManager.merge(member);
     }
 
@@ -76,7 +102,7 @@
         Root<UserGroupMember> root = query.from(UserGroupMember.class);
 
         Predicate predicate = criteriaBuilder.and(
-                criteriaBuilder.equal(root.get(UserGroupMember_.group),
+                criteriaBuilder.equal(root.get(UserGroupMember_.group).get("id"),
                         groupId),
                 criteriaBuilder.equal(root.get(UserGroupMember_.userId),
                         userId));
@@ -108,7 +134,7 @@
         Join<UserGroupMember, Role> memberRole = root.join("roles");
 
         Predicate predicate = criteriaBuilder.and(
-                criteriaBuilder.equal(root.get(UserGroupMember_.group),
+                criteriaBuilder.equal(root.get(UserGroupMember_.group).get("id"),
                         groupId),
                 criteriaBuilder.equal(memberRole.get(Role_.NAME), role));
 
@@ -136,7 +162,7 @@
         Root<UserGroupMember> root = query.from(UserGroupMember.class);
 
         Predicate predicate = criteriaBuilder.and(criteriaBuilder
-                .equal(root.get(UserGroupMember_.group), groupId));
+                .equal(root.get(UserGroupMember_.group).get("id"), groupId));
 
         query.select(root);
         query.where(predicate);
diff --git a/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java b/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
index d5d54ae..30f690c 100644
--- a/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
+++ b/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
@@ -4,6 +4,7 @@
 
 import de.ids_mannheim.korap.constant.PredefinedRole;
 import jakarta.persistence.Column;
+import jakarta.persistence.CascadeType;
 import jakarta.persistence.Entity;
 import jakarta.persistence.FetchType;
 import jakarta.persistence.GeneratedValue;
@@ -50,7 +51,7 @@
      * describe a member.
      * 
      */
-    @ManyToMany(fetch = FetchType.EAGER)
+    @ManyToMany(fetch = FetchType.EAGER, cascade = { CascadeType.MERGE })
     @JoinTable(name = "group_member_role", joinColumns = @JoinColumn(name = "group_member_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), uniqueConstraints = @UniqueConstraint(columnNames = {
             "group_member_id", "role_id" }))
     private Set<Role> roles;
diff --git a/src/main/java/de/ids_mannheim/korap/service/QueryService.java b/src/main/java/de/ids_mannheim/korap/service/QueryService.java
index e3b0ce6..f329ab0 100644
--- a/src/main/java/de/ids_mannheim/korap/service/QueryService.java
+++ b/src/main/java/de/ids_mannheim/korap/service/QueryService.java
@@ -178,10 +178,29 @@
         else if (query.getCreatedBy().equals(deletedBy)
                 || adminDao.isAdmin(deletedBy)) {
 
-            if (query.getType().equals(ResourceType.PUBLISHED)) {
-                UserGroup group = userGroupDao
-                        .retrieveHiddenGroupByQueryName(queryName);
-                userGroupDao.deleteGroup(group.getId(), deletedBy);
+            // If published, fetch the hidden group BEFORE deleting roles, so we can remove
+            // it later
+            UserGroup hiddenGroup = null;
+            boolean isPublished = query.getType().equals(ResourceType.PUBLISHED);
+            if (isPublished) {
+                hiddenGroup = userGroupDao.retrieveHiddenGroupByQueryName(queryName);
+            }
+
+            // Detach member-role links and delete all roles linked to the query
+            List<Role> queryRoles = roleDao.retrieveRolesByQueryIdWithMembers(query.getId());
+            for (Role role : queryRoles) {
+                if (role.getUserGroupMembers() != null) {
+                    for (UserGroupMember m : role.getUserGroupMembers()) {
+                        if (m.getRoles() != null && m.getRoles().remove(role)) {
+                            memberDao.updateMember(m);
+                        }
+                    }
+                }
+            }
+            roleDao.deleteRolesByQueryId(query.getId());
+
+            if (isPublished && hiddenGroup != null) {
+                userGroupDao.deleteGroup(hiddenGroup.getId(), deletedBy);
             }
             if (type.equals(QueryType.VIRTUAL_CORPUS)
                     && VirtualCorpusCache.contains(queryName)) {
diff --git a/src/main/resources/default-config.xml b/src/main/resources/default-config.xml
index a4a30d9..c82010f 100644
--- a/src/main/resources/default-config.xml
+++ b/src/main/resources/default-config.xml
@@ -125,9 +125,7 @@
 				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
 				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
 				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}
-				</prop>
-				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
-				<prop key="hibernate.cache.region.factory_class">${hibernate.cache.region.factory}</prop>
+				</prop>				
 				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>
 			</props>
 		</property>
diff --git a/src/main/resources/properties/hibernate.properties b/src/main/resources/properties/hibernate.properties
index e394a88..bd073e5 100644
--- a/src/main/resources/properties/hibernate.properties
+++ b/src/main/resources/properties/hibernate.properties
@@ -3,6 +3,7 @@
 hibernate.show_sql=false
 hibernate.cache.use_query_cache=false
 hibernate.cache.use_second_level_cache=false
-hibernate.cache.provider=org.hibernate.cache.EhCacheProvider
-hibernate.cache.region.factory=org.hibernate.cache.ehcache.EhCacheRegionFactory
-hibernate.jdbc.time_zone=UTC
\ No newline at end of file
+hibernate.jdbc.time_zone=UTC
+# Ehcache 2 integration removed; disable legacy cache settings
+hibernate.cache.provider=
+hibernate.cache.region.factory=
diff --git a/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java
index 9e94ca5..d5773a9 100644
--- a/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java
+++ b/src/test/java/de/ids_mannheim/korap/dao/VirtualCorpusDaoTest.java
@@ -73,9 +73,14 @@
                             "experimental", false, "system", null, null);
                 });
 
-        assertEquals("Converting `org.hibernate.exception.GenericJDBCException` "
-                        + "to JPA `PersistenceException` : could not execute statement",
-            exception.getMessage());
+        String msg = exception.getMessage();
+        // Hibernate 7 exposes provider/DB-specific message; assert key parts
+        org.junit.jupiter.api.Assertions.assertTrue(
+                msg.contains("could not execute statement"),
+                () -> "Unexpected message: " + msg);
+        org.junit.jupiter.api.Assertions.assertTrue(
+                msg.contains("UNIQUE") || msg.contains("constraint"),
+                () -> "Expected unique constraint error in message: " + msg);
     }
 
     @Test
diff --git a/src/test/resources/test-config.xml b/src/test/resources/test-config.xml
index b5b676b..05311db 100644
--- a/src/test/resources/test-config.xml
+++ b/src/test/resources/test-config.xml
@@ -147,11 +147,8 @@
 				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
 				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
 				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}
-				</prop>
-				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
-				<prop key="hibernate.cache.region.factory_class">${hibernate.cache.region.factory}</prop>
-				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>
-				<!-- <prop key="net.sf.ehcache.configurationResourceName">classpath:ehcache.xml</prop> -->
+				</prop>				
+				<prop key="hibernate.jdbc.time_zone">${hibernate.jdbc.time_zone}</prop>				
 			</props>
 		</property>
 	</bean>
diff --git a/src/test/resources/test-hibernate.properties b/src/test/resources/test-hibernate.properties
index e394a88..63697a7 100644
--- a/src/test/resources/test-hibernate.properties
+++ b/src/test/resources/test-hibernate.properties
@@ -3,6 +3,6 @@
 hibernate.show_sql=false
 hibernate.cache.use_query_cache=false
 hibernate.cache.use_second_level_cache=false
-hibernate.cache.provider=org.hibernate.cache.EhCacheProvider
-hibernate.cache.region.factory=org.hibernate.cache.ehcache.EhCacheRegionFactory
-hibernate.jdbc.time_zone=UTC
\ No newline at end of file
+hibernate.jdbc.time_zone=UTC
+hibernate.cache.provider=
+hibernate.cache.region.factory=