Change VC and query names to lowercase (close #902)

Make VC and query names consistent by storing them in lowercase in the
database and cache.
Handle VC and query name incase-sensitivity in database access.
Handle referTo in VirtualCorpusRewrite

Change-Id: I7cb94e9b007b6a07fc8b9dd7dda4249318c6c3a5
diff --git a/Changes b/Changes
index 3e25a0e..2a90c92 100644
--- a/Changes
+++ b/Changes
@@ -1,3 +1,6 @@
+# version 1.1-SNAPSHOT
+- Change VC and query names to lowercase
+
 # version 1.0.1
 
 - Add an exception for missing layer.
diff --git a/pom.xml b/pom.xml
index 9441e6b..954fab6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
 	<modelVersion>4.0.0</modelVersion>
 	<groupId>de.ids-mannheim.korap.kustvakt</groupId>
 	<artifactId>Kustvakt</artifactId>
-	<version>1.0.1</version>
+	<version>1.1-SNAPSHOT</version>
 	<properties>
 		<java.version>21</java.version>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
diff --git a/src/main/java/de/ids_mannheim/korap/core/service/BasicService.java b/src/main/java/de/ids_mannheim/korap/core/service/BasicService.java
index 6ea75dc..129a392 100644
--- a/src/main/java/de/ids_mannheim/korap/core/service/BasicService.java
+++ b/src/main/java/de/ids_mannheim/korap/core/service/BasicService.java
@@ -81,6 +81,7 @@
                 }
             }
 
+            vcName = vcName.toLowerCase();
             String vcInCaching = config.getVcInCaching();
             if (vcName.equals(vcInCaching)) {
                 throw new KustvaktException(
diff --git a/src/main/java/de/ids_mannheim/korap/dao/QueryDao.java b/src/main/java/de/ids_mannheim/korap/dao/QueryDao.java
index 93a75e9..3936550 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/QueryDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/QueryDao.java
@@ -201,7 +201,8 @@
 
         Predicate condition = builder.and(
                 builder.equal(query.get(QueryDO_.createdBy), createdBy),
-                builder.equal(query.get(QueryDO_.name), queryName));
+                builder.equal(query.get(QueryDO_.name),
+                        queryName.toLowerCase()));
 
         criteriaQuery.select(query);
         criteriaQuery.where(condition);
@@ -364,4 +365,4 @@
         return q.getSingleResult();
     }
 
-}
+}
\ No newline at end of file
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 b2c09cf..9e0cb99 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/RoleDao.java
@@ -245,11 +245,12 @@
                         cb.equal(groupRole.get(UserGroup_.name), groupName),
                         cb.equal(queryRole.get(QueryDO_.createdBy),
                                 queryCreator),
-                        cb.equal(queryRole.get(QueryDO_.name), queryName)));
+                        cb.equal(queryRole.get(QueryDO_.name),
+                                queryName.toLowerCase())));
 
        
         delete.where(deleteRole.get(Role_.id).in(subquery));
         entityManager.createQuery(delete).executeUpdate();
     }
 
-}
+}
\ No newline at end of file
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 8627f1d..af8742a 100644
--- a/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java
+++ b/src/main/java/de/ids_mannheim/korap/dao/UserGroupDao.java
@@ -118,7 +118,6 @@
         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)) {
@@ -275,7 +274,8 @@
         Predicate p = criteriaBuilder.and(
                 criteriaBuilder.equal(root.get(UserGroup_.status),
                         UserGroupStatus.HIDDEN),
-                criteriaBuilder.equal(query_role.get(QueryDO_.name), queryName)
+                criteriaBuilder.equal(criteriaBuilder.lower(query_role.get(QueryDO_.name)),
+                        queryName.toLowerCase())
         );
 
         criteriaQuery.select(root);
@@ -383,4 +383,4 @@
         }
 
     }
-}
+}
\ No newline at end of file
diff --git a/src/main/java/de/ids_mannheim/korap/init/NamedVCLoader.java b/src/main/java/de/ids_mannheim/korap/init/NamedVCLoader.java
index d1fd84b..f9e40eb 100644
--- a/src/main/java/de/ids_mannheim/korap/init/NamedVCLoader.java
+++ b/src/main/java/de/ids_mannheim/korap/init/NamedVCLoader.java
@@ -147,6 +147,7 @@
     private void processVC (String vcId, String json, double apiVersion)
             throws IOException, QueryException {
         boolean updateCache = false;
+        vcId = vcId.toLowerCase();
         try {
             // if VC exists in the DB
             QueryDO existingVC = vcService.searchQueryByName("system", vcId, "system",
diff --git a/src/main/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewrite.java b/src/main/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewrite.java
index 88448b8..524fe71 100644
--- a/src/main/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewrite.java
+++ b/src/main/java/de/ids_mannheim/korap/rewrite/VirtualCorpusRewrite.java
@@ -44,6 +44,7 @@
         return node;
     }
 
+    // EM: can it handle multiple vc refs?
     private void findVCRef (String username, KoralNode koralNode)
             throws KustvaktException {
         if (koralNode.has("@type")
@@ -64,6 +65,9 @@
                         ownerExist = true;
                     }
                 }
+                
+                String originalVcName = new String(vcName);
+                vcName = vcName.toLowerCase();
 
                 String vcInCaching = config.getVcInCaching();
                 if (vcName.equals(vcInCaching)) {
@@ -74,6 +78,7 @@
                             koralNode.get("ref"));
                 }
 
+                // ref is not lower case
                 QueryDO vc = queryService.searchQueryByName(username, vcName,
                         vcOwner, QueryType.VIRTUAL_CORPUS);
                 if (!vc.isCached()) {
@@ -81,7 +86,10 @@
                 }
                 // required for named-vc since they are stored by filenames in the cache
                 else if (ownerExist) {
-                    removeOwner(vc.getKoralQuery(), vcOwner, koralNode);
+                    removeOwner(originalVcName, vcName, vcOwner, koralNode);
+                }
+                else if (!originalVcName.equals(vcName)) {
+                	lowerVCName(originalVcName, vcName, koralNode);
                 }
             }
 
@@ -98,15 +106,28 @@
         }
     }
 
-    private void removeOwner (String koralQuery, String vcOwner,
-            KoralNode koralNode) throws KustvaktException {
+	private void removeOwner (String originalVcName, String vcName, 
+			String vcOwner, KoralNode koralNode)
+			throws KustvaktException {
         JsonNode jsonNode = koralNode.rawNode();
         String ref = jsonNode.at("/ref").asText();
+        ref = ref.replace(originalVcName, vcName);
         String newRef = ref.substring(vcOwner.length() + 1, ref.length());
         koralNode.replace("ref", newRef, new RewriteIdentifier("ref", ref, 
         		"Ref has been replaced. The original value is described at "
         		+ "the original property."));
     }
+    
+	private void lowerVCName (String originalVcName, String vcName, 
+			KoralNode koralNode)
+			throws KustvaktException {
+        JsonNode jsonNode = koralNode.rawNode();
+        String ref = jsonNode.at("/ref").asText();
+        String newRef = ref.replace(originalVcName, vcName);
+        koralNode.replace("ref", newRef, new RewriteIdentifier("ref", ref, 
+        		"Ref has been replaced. The original value is described at "
+        		+ "the original property."));
+    }
 
     protected void rewriteVC (QueryDO vc, KoralNode koralNode)
             throws KustvaktException {
@@ -133,4 +154,5 @@
 //                new RewriteIdentifier("ref", "", jsonNode.at("/ref").asText()));
 //        koralNode.setAll((ObjectNode) kq);
     }
+
 }
diff --git a/src/main/java/de/ids_mannheim/korap/service/QueryServiceImpl.java b/src/main/java/de/ids_mannheim/korap/service/QueryServiceImpl.java
index 095965c..1de9086 100644
--- a/src/main/java/de/ids_mannheim/korap/service/QueryServiceImpl.java
+++ b/src/main/java/de/ids_mannheim/korap/service/QueryServiceImpl.java
@@ -75,7 +75,7 @@
     public static boolean DEBUG = false;
 
     public static Pattern queryNamePattern = Pattern
-            .compile("[a-zA-Z0-9]+[a-zA-Z_0-9-.]*");
+            .compile("[a-z0-9]+[a-z_0-9-.]*");
 
     @Autowired
     private QueryDao queryDao;
@@ -258,6 +258,7 @@
         verifyUsername(username, queryCreator);
         QueryDO query = queryDao.retrieveQueryByName(queryName, queryCreator);
 
+        queryName = queryName.toLowerCase();
         if (query == null) {
             storeQuery(queryJson, queryName, queryCreator, username, 
             		apiVersion);
diff --git a/src/test/java/de/ids_mannheim/korap/dao/QueryCaseSensitivityTest.java b/src/test/java/de/ids_mannheim/korap/dao/QueryCaseSensitivityTest.java
new file mode 100644
index 0000000..75061cc
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/QueryCaseSensitivityTest.java
@@ -0,0 +1,122 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.QueryType;
+import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.constant.UserGroupStatus;
+import de.ids_mannheim.korap.constant.PredefinedRole;
+import de.ids_mannheim.korap.constant.PrivilegeType;
+import de.ids_mannheim.korap.entity.QueryDO;
+import de.ids_mannheim.korap.entity.Role;
+import de.ids_mannheim.korap.entity.UserGroup;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.user.User;
+
+/** Generated by Github Copilot
+ * 
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class QueryCaseSensitivityTest extends DaoTestBase {
+
+    @Autowired
+    private QueryDao queryDao;
+    @Autowired
+    private RoleDao roleDao;
+    @Autowired
+    private UserGroupDao userGroupDao;
+
+    @Test
+    public void retrieveQueryByName_caseInsensitive () throws KustvaktException {
+        String creator = "caseUser";
+        String name = "casevc";
+        int id = queryDao.createQuery(name, ResourceType.PRIVATE,
+                QueryType.VIRTUAL_CORPUS, User.CorpusAccess.FREE, "koral",
+                "def", "desc", "status", false, creator, null, null, null);
+
+        QueryDO created = queryDao.retrieveQueryById(id);
+        assertEquals(name, created.getName());
+
+        // lookup with different case for the query name
+        QueryDO found = queryDao.retrieveQueryByName("CaseVC", creator);
+        assertNotNull(found);
+        assertEquals(id, found.getId());
+
+        // cleanup
+        queryDao.deleteQuery(created);
+    }
+
+    @Test
+    public void retrieveHiddenGroupByQueryName_caseInsensitive () throws KustvaktException {
+        String groupCreator = "groupCreator";
+        // create a hidden group
+        int gid = userGroupDao.createGroup("hidden-group", null, groupCreator,
+                UserGroupStatus.HIDDEN);
+        UserGroup group = userGroupDao.retrieveGroupById(gid, true);
+
+        // create a query with mixed case name
+        String qCreator = "queryCreator";
+        String qName = "publishedvc";
+        int qid = queryDao.createQuery(qName, ResourceType.PUBLISHED,
+                QueryType.VIRTUAL_CORPUS, User.CorpusAccess.FREE, "koral",
+                "def", "desc", "status", false, qCreator, null, null, null);
+        QueryDO query = queryDao.retrieveQueryById(qid);
+
+        // attach a role linking the group and the query
+        Role r = new Role(PredefinedRole.GROUP_ADMIN, PrivilegeType.READ_MEMBER,
+                group, query);
+        roleDao.addRole(r);
+
+        // lookup hidden group by query name using different case
+        UserGroup found = userGroupDao.retrieveHiddenGroupByQueryName("PublishedVC");
+        assertNotNull(found);
+        assertEquals(gid, found.getId());
+
+        // cleanup
+        deleteUserGroup(gid, groupCreator);
+        queryDao.deleteQuery(query);
+    }
+
+    @Test
+    public void deleteRoleByGroupAndQuery_queryName_caseInsensitive () throws KustvaktException {
+        // create a fresh group using helper
+        UserGroup group = createUserGroup("del-group", "deleter");
+
+        String qCreator = "queryCreator";
+        String qName = "delvc";
+        int qid = queryDao.createQuery(qName, ResourceType.PRIVATE,
+                QueryType.VIRTUAL_CORPUS, User.CorpusAccess.FREE, "koral",
+                "def", "desc", "status", false, qCreator, null, null, null);
+        QueryDO query = queryDao.retrieveQueryById(qid);
+
+        // add a role linking group and query
+        Role r = new Role(PredefinedRole.GROUP_ADMIN, PrivilegeType.READ_MEMBER,
+                group, query);
+        roleDao.addRole(r);
+
+        // ensure role exists for the query
+        List<Role> rolesBefore = roleDao.retrieveRolesByQueryIdWithMembers(qid);
+        assertTrue(rolesBefore.size() >= 1);
+
+        // delete by group name and different-case query name
+        roleDao.deleteRoleByGroupAndQuery(group.getName(), qCreator, "DelVC");
+
+        List<Role> rolesAfter = roleDao.retrieveRolesByQueryIdWithMembers(qid);
+        assertEquals(0, rolesAfter.size());
+
+        // cleanup
+        deleteUserGroup(group.getId(), "deleter");
+        queryDao.deleteQuery(query);
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusControllerTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusControllerTest.java
index dd0e4b8..b85dcde 100644
--- a/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusControllerTest.java
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusControllerTest.java
@@ -71,6 +71,7 @@
         assertEquals("new_vc", node.get(1).get("name").asText());
         
         testCreateVC_sameName(testUser, "new_vc", ResourceType.PRIVATE);
+        testCreateVC_sameName(testUser, "NEW_VC", ResourceType.PRIVATE);
         
         // delete new VC
         deleteVC("new_vc", testUser, testUser);
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusReferenceTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusReferenceTest.java
index da1cb9e..2580a0a 100644
--- a/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusReferenceTest.java
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/vc/VirtualCorpusReferenceTest.java
@@ -51,12 +51,15 @@
         assertTrue(VirtualCorpusCache.contains("named-vc1"));
         JsonNode node = testSearchWithRef_VC1();
         assertEquals(numOfMatches, node.at("/matches").size());
+        testSearchIncaseSensitivity_VC1(numOfMatches);
+        
         testStatisticsWithRef();
         numOfMatches = testSearchWithoutRef_VC2();
         vcLoader.loadVCToCache("named-vc2", "/vc/named-vc2.jsonld");
         assertTrue(VirtualCorpusCache.contains("named-vc2"));
         node = testSearchWithRef_VC2();
         assertEquals(numOfMatches, node.at("/matches").size());
+        testSearchIncaseSensitivity_VC2(numOfMatches);
         
         testDeleteVC("named-vc1", "system", admin);
         testDeleteVC("named-vc2", "system", admin);
@@ -96,6 +99,16 @@
         String ent = response.readEntity(String.class);
         return JsonUtils.readTree(ent);
     }
+    
+	private void testSearchIncaseSensitivity_VC1 (int numOfMatches) throws KustvaktException {
+		Response response = target().path(API_VERSION).path("search")
+				.queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+				.queryParam("cq", "referTo \"system/Named-VC1\"").request()
+				.get();
+		String ent = response.readEntity(String.class);
+		JsonNode node = JsonUtils.readTree(ent);
+		assertEquals(numOfMatches, node.at("/matches").size());
+	}
 
     private JsonNode testSearchWithRef_VC2 () throws KustvaktException {
         Response response = target().path(API_VERSION).path("search")
@@ -104,6 +117,17 @@
         String ent = response.readEntity(String.class);
         return JsonUtils.readTree(ent);
     }
+    
+    private void testSearchIncaseSensitivity_VC2 (int numOfMatches) 
+    		throws KustvaktException {
+		Response response = target().path(API_VERSION).path("search")
+				.queryParam("q", "[orth=der]").queryParam("ql", "poliqarp")
+				.queryParam("cq", "referTo \"Named-VC2\"").request()
+				.get();
+		String ent = response.readEntity(String.class);
+		JsonNode node = JsonUtils.readTree(ent);
+		assertEquals(numOfMatches, node.at("/matches").size());
+	}
 
     private void testStatisticsWithRef () throws KustvaktException {
         String corpusQuery = "availability = /CC.*/ & referTo named-vc1";