Use Blaze Persistence for criteria queries.

Blaze Persistence is more flexible, but also less type-safe than JPA
Criteria.  This change explores what the changes look like and how
efficient the resulting queries are.

Change-Id: Ia47e4f0280d451e0381f58ece297c5bc06604289
diff --git a/pom.xml b/pom.xml
index cd94789..0efeb19 100644
--- a/pom.xml
+++ b/pom.xml
@@ -194,9 +194,22 @@
       <artifactId>quarkus-container-image-jib</artifactId>
     </dependency>
 
-
     <!-- Quarkus universe -->
     <dependency>
+      <groupId>com.blazebit</groupId>
+      <artifactId>blaze-persistence-integration-quarkus</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.blazebit</groupId>
+      <artifactId>blaze-persistence-integration-hibernate-5.6</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.blazebit</groupId>
+      <artifactId>blaze-persistence-entity-view-processor</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
       <groupId>org.apache.camel.quarkus</groupId>
       <artifactId>camel-quarkus-mail</artifactId>
     </dependency>
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index 241f005..02a11fa 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -2,13 +2,14 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.blazebit.persistence.CriteriaBuilder;
+import com.blazebit.persistence.CriteriaBuilderFactory;
 import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType;
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
 import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
 import eu.mulk.mulkcms2.benki.newsletter.Newsletter;
 import eu.mulk.mulkcms2.benki.users.User;
-import eu.mulk.mulkcms2.benki.users.User_;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.time.LocalDate;
 import java.time.OffsetDateTime;
@@ -22,13 +23,13 @@
 import java.util.Set;
 import java.util.TimeZone;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import javax.annotation.CheckForNull;
 import javax.annotation.Nullable;
 import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.CascadeType;
 import javax.persistence.Column;
 import javax.persistence.Entity;
+import javax.persistence.EntityManager;
 import javax.persistence.EnumType;
 import javax.persistence.Enumerated;
 import javax.persistence.FetchType;
@@ -45,12 +46,6 @@
 import javax.persistence.OneToMany;
 import javax.persistence.SequenceGenerator;
 import javax.persistence.Table;
-import javax.persistence.criteria.CriteriaBuilder;
-import javax.persistence.criteria.CriteriaQuery;
-import javax.persistence.criteria.From;
-import javax.persistence.criteria.JoinType;
-import javax.persistence.criteria.Predicate;
-import org.hibernate.Session;
 import org.hibernate.annotations.Type;
 import org.hibernate.annotations.TypeDef;
 
@@ -148,80 +143,76 @@
     }
   }
 
-  protected static <T extends Post> CriteriaQuery<T> queryViewable(
+  protected static <T extends Post> CriteriaBuilder<T> queryViewable(
       Class<T> entityClass,
       @CheckForNull User reader,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
-      CriteriaBuilder cb,
+      EntityManager em,
+      CriteriaBuilderFactory cbf,
       boolean forward,
       @CheckForNull String searchQuery) {
-    CriteriaQuery<T> query = cb.createQuery(entityClass);
 
-    var conditions = new ArrayList<Predicate>();
+    CriteriaBuilder<T> cb = cbf.create(em, entityClass).select("post");
 
-    From<?, T> post;
     if (reader == null) {
-      post = query.from(entityClass);
-      var target = post.join(Post_.targets);
-      conditions.add(cb.equal(target, Role.getWorld()));
+      cb =
+          cb.from(entityClass, "post")
+              .innerJoin("post.targets", "role")
+              .where("'world'")
+              .isMemberOf("role.tags");
     } else {
-      var root = query.from(User.class);
-      conditions.add(cb.equal(root, reader));
+      cb = cb.from(User.class, "user").where("user").eq(reader);
       if (entityClass.isAssignableFrom(Post.class)) {
-        post = (From<?, T>) root.join(User_.visiblePosts);
+        cb = cb.innerJoin("user.visiblePosts", "post");
       } else if (entityClass.isAssignableFrom(Bookmark.class)) {
-        post = (From<?, T>) root.join(User_.visibleBookmarks);
+        cb = cb.innerJoin("user.visibleBookmark", "post");
       } else if (entityClass.isAssignableFrom(LazychatMessage.class)) {
-        post = (From<?, T>) root.join(User_.visibleLazychatMessages);
+        cb = cb.innerJoin("user.visibleLazychatMessages", "post");
       } else {
         throw new IllegalArgumentException();
       }
     }
 
-    query.select(post);
-    post.fetch(Post_.owner, JoinType.LEFT);
+    cb = cb.fetch("post.owner");
 
     if (owner != null) {
-      conditions.add(cb.equal(post.get(Post_.owner), owner));
+      cb = cb.where("post.owner").eq(owner);
     }
 
     if (forward) {
-      query.orderBy(cb.desc(post.get(Post_.id)));
+      cb = cb.orderByDesc("post.id");
     } else {
-      query.orderBy(cb.asc(post.get(Post_.id)));
+      cb = cb.orderByAsc("post.id");
     }
 
     if (cursor != null) {
       if (forward) {
-        conditions.add(cb.le(post.get(Post_.id), cursor));
+        cb = cb.where("post.id").le(cursor);
       } else {
-        conditions.add(cb.gt(post.get(Post_.id), cursor));
+        cb = cb.where("post.id").gt(cursor);
       }
     }
 
     if (searchQuery != null && !searchQuery.isBlank()) {
-      var postTexts = post.join(Post_.texts);
-      var localizedSearches =
-          Stream.of("de", "en")
-              .map(
-                  language ->
-                      cb.isTrue(
-                          cb.function(
-                              "post_matches_websearch",
-                              Boolean.class,
-                              postTexts.get(PostText_.searchTerms),
-                              cb.literal(language),
-                              cb.literal(searchQuery))))
-              .toArray(n -> new Predicate[n]);
-      conditions.add(cb.or(localizedSearches));
+      cb =
+          cb.whereExists()
+              .from(PostText.class, "postText")
+              .where("postText.post")
+              .eqExpression("post")
+              .whereOr()
+              .whereExpression(
+                  "post_matches_websearch(postText.searchTerms, 'de', :searchQueryText) = true")
+              .whereExpression(
+                  "post_matches_websearch(postText.searchTerms, 'en', :searchQueryText) = true")
+              .endOr()
+              .end()
+              .setParameter("searchQueryText", searchQuery);
     }
 
-    conditions.add(cb.equal(post.get(Post_.scope), Scope.top_level));
+    cb = cb.where("post.scope").eq(Scope.top_level);
 
-    query.where(conditions.toArray(new Predicate[0]));
-
-    return query;
+    return cb;
   }
 
   public final boolean isVisibleTo(@Nullable User user) {
@@ -293,13 +284,18 @@
   }
 
   public static PostPage<Post<? extends PostText<?>>> findViewable(
-      PostFilter postFilter, Session session, @CheckForNull User viewer, @CheckForNull User owner) {
-    return findViewable(postFilter, session, viewer, owner, null, null, null);
+      PostFilter postFilter,
+      EntityManager em,
+      CriteriaBuilderFactory cbf,
+      @CheckForNull User viewer,
+      @CheckForNull User owner) {
+    return findViewable(postFilter, em, cbf, viewer, owner, null, null, null);
   }
 
   public static PostPage<Post<? extends PostText<?>>> findViewable(
       PostFilter postFilter,
-      Session session,
+      EntityManager em,
+      CriteriaBuilderFactory cbf,
       @CheckForNull User viewer,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
@@ -316,12 +312,13 @@
       default:
         entityClass = Post.class;
     }
-    return findViewable(entityClass, session, viewer, owner, cursor, count, searchQuery);
+    return findViewable(entityClass, em, cbf, viewer, owner, cursor, count, searchQuery);
   }
 
   protected static <T extends Post<? extends PostText<?>>> PostPage<T> findViewable(
       Class<? extends T> entityClass,
-      Session session,
+      EntityManager em,
+      CriteriaBuilderFactory cbf,
       @CheckForNull User viewer,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
@@ -332,10 +329,9 @@
       Objects.requireNonNull(count);
     }
 
-    var cb = session.getCriteriaBuilder();
-
-    var forwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, true, searchQuery);
-    var forwardQuery = session.createQuery(forwardCriteria);
+    var forwardCriteria =
+        queryViewable(entityClass, viewer, owner, cursor, em, cbf, true, searchQuery);
+    var forwardQuery = forwardCriteria.getQuery();
 
     if (count != null) {
       forwardQuery.setMaxResults(count + 1);
@@ -347,8 +343,8 @@
     if (cursor != null) {
       // Look backwards as well so we can find the prevCursor.
       var backwardCriteria =
-          queryViewable(entityClass, viewer, owner, cursor, cb, false, searchQuery);
-      var backwardQuery = session.createQuery(backwardCriteria);
+          queryViewable(entityClass, viewer, owner, cursor, em, cbf, false, searchQuery);
+      var backwardQuery = backwardCriteria.getQuery();
       backwardQuery.setMaxResults(count);
       var backwardResults = backwardQuery.getResultList();
       if (!backwardResults.isEmpty()) {
@@ -356,7 +352,7 @@
       }
     }
 
-    var forwardResults = (List<T>) forwardQuery.getResultList();
+    var forwardResults = new ArrayList<T>(forwardQuery.getResultList());
     if (count != null) {
       if (forwardResults.size() == count + 1) {
         nextCursor = forwardResults.get(count).id;
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
index e808b9c..8de4677 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -5,6 +5,7 @@
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.TEXT_HTML;
 
+import com.blazebit.persistence.CriteriaBuilderFactory;
 import com.rometools.rome.feed.atom.Content;
 import com.rometools.rome.feed.atom.Entry;
 import com.rometools.rome.feed.atom.Feed;
@@ -110,6 +111,8 @@
 
   @PersistenceContext protected EntityManager entityManager;
 
+  @Inject protected CriteriaBuilderFactory criteriaBuilderFactory;
+
   private final SecureRandom secureRandom;
 
   private final PostFilter postFilter;
@@ -132,8 +135,16 @@
     maxResults = maxResults == null ? defaultMaxResults : maxResults;
 
     @CheckForNull var reader = getCurrentUser();
-    var session = entityManager.unwrap(Session.class);
-    var q = Post.findViewable(postFilter, session, reader, null, cursor, maxResults, searchQuery);
+    var q =
+        Post.findViewable(
+            postFilter,
+            entityManager,
+            criteriaBuilderFactory,
+            reader,
+            null,
+            cursor,
+            maxResults,
+            searchQuery);
 
     q.cacheDescriptions();
 
@@ -170,8 +181,16 @@
 
     @CheckForNull var reader = getCurrentUser();
     var owner = User.findByNickname(ownerName);
-    var session = entityManager.unwrap(Session.class);
-    var q = Post.findViewable(postFilter, session, reader, owner, cursor, maxResults, null);
+    var q =
+        Post.findViewable(
+            postFilter,
+            entityManager,
+            criteriaBuilderFactory,
+            reader,
+            owner,
+            cursor,
+            maxResults,
+            null);
 
     q.cacheDescriptions();
 
@@ -280,7 +299,7 @@
   private String makeFeed(
       @CheckForNull User reader, @Nullable User owner, @Nullable String ownerName)
       throws FeedException {
-    var q = Post.findViewable(postFilter, entityManager.unwrap(Session.class), reader, owner);
+    var q = Post.findViewable(postFilter, entityManager, criteriaBuilderFactory, reader, owner);
     q.cacheDescriptions();
     var posts = q.posts;
 
diff --git a/src/main/java/eu/mulk/mulkcms2/common/hibernate/HibernateMetadataBuilderContributor.java b/src/main/java/eu/mulk/mulkcms2/common/hibernate/HibernateMetadataBuilderContributor.java
new file mode 100644
index 0000000..31f99b2
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/common/hibernate/HibernateMetadataBuilderContributor.java
@@ -0,0 +1,12 @@
+package eu.mulk.mulkcms2.common.hibernate;
+
+import org.hibernate.boot.MetadataBuilder;
+import org.hibernate.boot.spi.MetadataBuilderContributor;
+
+public final class HibernateMetadataBuilderContributor implements MetadataBuilderContributor {
+
+  @Override
+  public void contribute(MetadataBuilder metadataBuilder) {
+    metadataBuilder.applySqlFunction("post_matches_websearch", new PostMatchesWebsearchFunction());
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/common/hibernate/PostMatchesWebsearchFunction.java b/src/main/java/eu/mulk/mulkcms2/common/hibernate/PostMatchesWebsearchFunction.java
new file mode 100644
index 0000000..9c10615
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/common/hibernate/PostMatchesWebsearchFunction.java
@@ -0,0 +1,11 @@
+package eu.mulk.mulkcms2.common.hibernate;
+
+import org.hibernate.dialect.function.SQLFunctionTemplate;
+import org.hibernate.type.BooleanType;
+
+public final class PostMatchesWebsearchFunction extends SQLFunctionTemplate {
+
+  public PostMatchesWebsearchFunction() {
+    super(BooleanType.INSTANCE, "(?1 @@ websearch_to_tsquery(language_regconfig(?2), ?3))");
+  }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index bbffd39..ee95952 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,5 +1,3 @@
-quarkus.log.console.color = true
-
 quarkus.log.level = INFO
 #quarkus.log.category."org.hibernate".level = INFO
 #quarkus.log.category."io.quarkus.oidc".level = FINEST
@@ -16,6 +14,8 @@
 quarkus.datasource.jdbc.max-size = 8
 quarkus.datasource.jdbc.min-size = 0
 
+quarkus.hibernate-orm.metadata-builder-contributor = eu.mulk.mulkcms2.common.hibernate.HibernateMetadataBuilderContributor
+
 quarkus.liquibase.migrate-at-start = true
 
 %dev.quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:5432/mulkcms