blog: Refactoring, DTO projection, native queries.

Change-Id: Ie073cbe0f4e3da88af7cfb062f6c18d8f5c39df2
diff --git a/blog/SLIDES.adoc b/blog/SLIDES.adoc
new file mode 100644
index 0000000..fe5bd01
--- /dev/null
+++ b/blog/SLIDES.adoc
@@ -0,0 +1,122 @@
+= Highly Efficient Enterprise Data Access
+Matthias Andreas Benkard
+// Meta
+:experimental:
+:data-uri:
+:sectnums:
+:toc:
+:stem:
+:toclevels: 2
+:description: Slides for my Hibernate Patterns and Antipatterns talk
+:keywords: mulk
+// Settings
+:icons: font
+:source-highlighter: rouge
+
+
+[source,sql]
+----
+Hibernate: 
+    select
+        post0_.id as id1_4_0_,
+        comments1_.id as id1_3_1_,
+        category3_.id as id1_2_2_,
+        post0_.author_id as author_i5_4_0_,
+        post0_.body as body2_4_0_,
+        post0_.publication_date as publicat3_4_0_,
+        post0_.title as title4_4_0_,
+        comments1_.author_name as author_n2_3_1_,
+        comments1_.post_id as post_id6_3_1_,
+        comments1_.publication_date as publicat3_3_1_,
+        comments1_.spam_status as spam_sta4_3_1_,
+        comments1_.text as text5_3_1_,
+        comments1_.post_id as post_id6_3_0__,
+        comments1_.id as id1_3_0__,
+        category3_.name as name2_2_2_,
+        categories2_.post_id as post_id1_5_1__,
+        categories2_.categories_id as categori2_5_1__ 
+    from
+        post post0_ 
+    left outer join
+        comment comments1_ 
+            on post0_.id=comments1_.post_id 
+    left outer join
+        post_category categories2_ 
+            on post0_.id=categories2_.post_id 
+    left outer join
+        category category3_ 
+            on categories2_.categories_id=category3_.id;
+----
+
+[source]
+----
+[2021-02-27 10:32:58] 60 rows retrieved starting from 1 in 263 ms (execution: 12 ms, fetching: 251 ms)
+
+; 60 = #posts (10) x #comments/post (3) x #categories (2)
+; cartesian explosion!
+----
+
+[source,sql]
+----
+post_id	com_id	cat_id	aut_id	body	pubdate	title			author_name			postid2	com_pubdate			spamp	com_text	postid3	com_id	cat_name	postid4	cat_id
+4	16	44	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133969	UNKNOWN	First post	4	16	Category #1	4	44
+4	15	44	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133963	UNKNOWN	First post	4	15	Category #1	4	44
+4	14	44	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133870	UNKNOWN	First post	4	14	Category #1	4	44
+4	16	45	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133969	UNKNOWN	First post	4	16	Category #0	4	45
+4	15	45	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133963	UNKNOWN	First post	4	15	Category #0	4	45
+4	14	45	1	""	2021-02-27 10:32:16.129627	Post #0	Anonymous Coward	4	2021-02-27 10:32:16.133870	UNKNOWN	First post	4	14	Category #0	4	45
+5	19	44	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135200	UNKNOWN	First post	5	19	Category #1	5	44
+5	18	44	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135192	UNKNOWN	First post	5	18	Category #1	5	44
+5	17	44	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135205	UNKNOWN	First post	5	17	Category #1	5	44
+5	19	45	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135200	UNKNOWN	First post	5	19	Category #0	5	45
+5	18	45	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135192	UNKNOWN	First post	5	18	Category #0	5	45
+5	17	45	2	""	2021-02-27 10:32:16.129674	Post #1	Anonymous Coward	5	2021-02-27 10:32:16.135205	UNKNOWN	First post	5	17	Category #0	5	45
+6	22	44	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136043	UNKNOWN	First post	6	22	Category #1	6	44
+6	21	44	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136038	UNKNOWN	First post	6	21	Category #1	6	44
+6	20	44	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136031	UNKNOWN	First post	6	20	Category #1	6	44
+6	22	45	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136043	UNKNOWN	First post	6	22	Category #0	6	45
+6	21	45	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136038	UNKNOWN	First post	6	21	Category #0	6	45
+6	20	45	3	""	2021-02-27 10:32:16.129700	Post #2	Anonymous Coward	6	2021-02-27 10:32:16.136031	UNKNOWN	First post	6	20	Category #0	6	45
+7	25	44	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136904	UNKNOWN	First post	7	25	Category #1	7	44
+7	24	44	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136897	UNKNOWN	First post	7	24	Category #1	7	44
+7	23	44	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136909	UNKNOWN	First post	7	23	Category #1	7	44
+7	25	45	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136904	UNKNOWN	First post	7	25	Category #0	7	45
+7	24	45	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136897	UNKNOWN	First post	7	24	Category #0	7	45
+7	23	45	1	""	2021-02-27 10:32:16.129724	Post #3	Anonymous Coward	7	2021-02-27 10:32:16.136909	UNKNOWN	First post	7	23	Category #0	7	45
+8	28	44	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137743	UNKNOWN	First post	8	28	Category #1	8	44
+8	27	44	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137739	UNKNOWN	First post	8	27	Category #1	8	44
+8	26	44	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137731	UNKNOWN	First post	8	26	Category #1	8	44
+8	28	45	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137743	UNKNOWN	First post	8	28	Category #0	8	45
+8	27	45	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137739	UNKNOWN	First post	8	27	Category #0	8	45
+8	26	45	2	""	2021-02-27 10:32:16.129746	Post #4	Anonymous Coward	8	2021-02-27 10:32:16.137731	UNKNOWN	First post	8	26	Category #0	8	45
+9	31	44	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138536	UNKNOWN	First post	9	31	Category #1	9	44
+9	30	44	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138548	UNKNOWN	First post	9	30	Category #1	9	44
+9	29	44	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138543	UNKNOWN	First post	9	29	Category #1	9	44
+9	31	45	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138536	UNKNOWN	First post	9	31	Category #0	9	45
+9	30	45	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138548	UNKNOWN	First post	9	30	Category #0	9	45
+9	29	45	3	""	2021-02-27 10:32:16.129767	Post #5	Anonymous Coward	9	2021-02-27 10:32:16.138543	UNKNOWN	First post	9	29	Category #0	9	45
+10	34	44	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139349	UNKNOWN	First post	10	34	Category #1	10	44
+10	33	44	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139354	UNKNOWN	First post	10	33	Category #1	10	44
+10	32	44	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139337	UNKNOWN	First post	10	32	Category #1	10	44
+10	34	45	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139349	UNKNOWN	First post	10	34	Category #0	10	45
+10	33	45	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139354	UNKNOWN	First post	10	33	Category #0	10	45
+10	32	45	1	""	2021-02-27 10:32:16.129789	Post #6	Anonymous Coward	10	2021-02-27 10:32:16.139337	UNKNOWN	First post	10	32	Category #0	10	45
+11	37	44	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140032	UNKNOWN	First post	11	37	Category #1	11	44
+11	36	44	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140025	UNKNOWN	First post	11	36	Category #1	11	44
+11	35	44	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140037	UNKNOWN	First post	11	35	Category #1	11	44
+11	37	45	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140032	UNKNOWN	First post	11	37	Category #0	11	45
+11	36	45	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140025	UNKNOWN	First post	11	36	Category #0	11	45
+11	35	45	2	""	2021-02-27 10:32:16.129809	Post #7	Anonymous Coward	11	2021-02-27 10:32:16.140037	UNKNOWN	First post	11	35	Category #0	11	45
+12	40	44	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140766	UNKNOWN	First post	12	40	Category #1	12	44
+12	39	44	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140786	UNKNOWN	First post	12	39	Category #1	12	44
+12	38	44	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140779	UNKNOWN	First post	12	38	Category #1	12	44
+12	40	45	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140766	UNKNOWN	First post	12	40	Category #0	12	45
+12	39	45	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140786	UNKNOWN	First post	12	39	Category #0	12	45
+12	38	45	3	""	2021-02-27 10:32:16.129839	Post #8	Anonymous Coward	12	2021-02-27 10:32:16.140779	UNKNOWN	First post	12	38	Category #0	12	45
+13	43	44	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141651	UNKNOWN	First post	13	43	Category #1	13	44
+13	42	44	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141655	UNKNOWN	First post	13	42	Category #1	13	44
+13	41	44	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141642	UNKNOWN	First post	13	41	Category #1	13	44
+13	43	45	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141651	UNKNOWN	First post	13	43	Category #0	13	45
+13	42	45	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141655	UNKNOWN	First post	13	42	Category #0	13	45
+13	41	45	1	""	2021-02-27 10:32:16.129860	Post #9	Anonymous Coward	13	2021-02-27 10:32:16.141642	UNKNOWN	First post	13	41	Category #0	13	45
+----
diff --git a/blog/src/main/java/eu/mulk/demos/blog/DemoDataLoader.java b/blog/src/main/java/eu/mulk/demos/blog/DemoDataLoader.java
index cbaed9a..bbd5cb0 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/DemoDataLoader.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/DemoDataLoader.java
@@ -1,7 +1,12 @@
 package eu.mulk.demos.blog;
 
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
+import eu.mulk.demos.blog.authors.Author;
+import eu.mulk.demos.blog.comments.Comment;
+import eu.mulk.demos.blog.posts.Category;
+import eu.mulk.demos.blog.posts.Post;
 import io.quarkus.runtime.StartupEvent;
 import java.util.stream.Stream;
 import javax.enterprise.context.ApplicationScoped;
@@ -47,7 +52,7 @@
       post.comments =
           nat(COMMENT_COUNT)
               .map(x -> Comment.create(post, "Anonymous Coward", "First post"))
-              .collect(toList());
+              .collect(toSet());
       post.comments.forEach(em::persist);
     }
 
@@ -55,7 +60,7 @@
     var categories =
         nat(CATEGORY_COUNT)
             .map(x -> Category.create("Category #%d".formatted(x)))
-            .collect(toList());
+            .collect(toSet());
     categories.forEach(em::persist);
     for (var post : posts) {
       post.categories = categories;
diff --git a/blog/src/main/java/eu/mulk/demos/blog/PostResource.java b/blog/src/main/java/eu/mulk/demos/blog/PostResource.java
deleted file mode 100644
index 532e873..0000000
--- a/blog/src/main/java/eu/mulk/demos/blog/PostResource.java
+++ /dev/null
@@ -1,138 +0,0 @@
-package eu.mulk.demos.blog;
-
-import java.util.List;
-import java.util.Set;
-import javax.transaction.Transactional;
-import javax.ws.rs.GET;
-import javax.ws.rs.Path;
-import javax.ws.rs.Produces;
-import javax.ws.rs.core.MediaType;
-import org.hibernate.annotations.LazyToOne;
-import org.hibernate.annotations.LazyToOneOption;
-import org.jboss.logging.Logger;
-
-@Path("/posts")
-public class PostResource {
-
-  static final Logger log = Logger.getLogger(PostResource.class);
-
-  /**
-   * Fetches all posts with no extra information.
-   *
-   * Simple.  No surprises.
-   */
-  @GET
-  @Produces(MediaType.TEXT_PLAIN)
-  @Transactional
-  public List<Post> getAll() {
-    clearLog();
-
-    return Post.findAll().list();
-  }
-
-  /**
-   * Fetches all posts with comments included.
-   *
-   * Lazy fetching.  Simple.  No surprises.
-   */
-  @GET
-  @Produces(MediaType.TEXT_PLAIN)
-  @Transactional
-  @Path("/q1")
-  public List<Post> getAllWithComments() {
-    clearLog();
-
-    return Post.find(
-        """
-            SELECT p FROM Post p
-              LEFT JOIN FETCH p.comments 
-            """)
-        .list();
-  }
-
-  /**
-   * Fetches all posts with author info included.
-   *
-   * <strong>Oops!</strong>
-   *
-   * {@link LazyToOne} with {@link LazyToOneOption#NO_PROXY} is needed to make this efficient.
-   */
-  @GET
-  @Produces(MediaType.TEXT_PLAIN)
-  @Transactional
-  @Path("/q2")
-  public List<Post> getAllWithAuthors() {
-    clearLog();
-
-    return Post.find(
-        """
-            SELECT p FROM Post p
-              LEFT JOIN FETCH p.author 
-            """)
-        .list();
-  }
-
-  /**
-   * Fetches all posts with comments and category info included.
-   *
-   * <strong>Oops!</strong>  Crashes.
-   *
-   * Either use {@link Set} and get bad performance or do it as in {@link
-   * #getAllWithCommentsAndCategories2()}.
-   */
-  @GET
-  @Produces(MediaType.TEXT_PLAIN)
-  @Transactional
-  @Path("/q3")
-  public List<Post> getAllWithCommentsAndCategories() {
-    clearLog();
-
-    return Post.find(
-        """
-            SELECT p FROM Post p
-              LEFT JOIN FETCH p.comments
-              LEFT JOIN FETCH p.categories
-            """)
-        .list();
-  }
-
-  /**
-   * Fetches all posts with comments and category info included.
-   *
-   * 2 queries, but hey, no cartesian explosion!  Works really well.
-   */
-  @GET
-  @Produces(MediaType.TEXT_PLAIN)
-  @Transactional
-  @Path("/q4")
-  public List<Post> getAllWithCommentsAndCategories2() {
-    clearLog();
-
-    List<Post> posts = Post.find(
-        """
-            SELECT p FROM Post p
-              LEFT JOIN FETCH p.comments
-            """)
-        .list();
-
-    posts = Post.find(
-        """
-            SELECT DISTINCT p FROM Post p
-              LEFT JOIN FETCH p.categories
-             WHERE p IN (?1)
-            """,
-        posts)
-        .list();
-
-    return posts;
-  }
-
-  private static void clearLog() {
-    log.infof("""
-                
-        -----------------------------------------------------
-        -------------------- NEW REQUEST --------------------
-        -----------------------------------------------------
-        """);
-  }
-}
diff --git a/blog/src/main/java/eu/mulk/demos/blog/Author.java b/blog/src/main/java/eu/mulk/demos/blog/authors/Author.java
similarity index 84%
rename from blog/src/main/java/eu/mulk/demos/blog/Author.java
rename to blog/src/main/java/eu/mulk/demos/blog/authors/Author.java
index d544d2e..e3c936f 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/Author.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/authors/Author.java
@@ -1,6 +1,7 @@
-package eu.mulk.demos.blog;
+package eu.mulk.demos.blog.authors;
 
 import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
 import javax.persistence.OneToOne;
@@ -14,6 +15,7 @@
 
   @OneToOne(fetch = FetchType.LAZY, mappedBy = "author")
   @LazyToOne(LazyToOneOption.NO_PROXY)
+  @JsonbTransient
   public BasicCredentials basicCredentials;
 
   public static Author create(String name) {
diff --git a/blog/src/main/java/eu/mulk/demos/blog/BasicCredentials.java b/blog/src/main/java/eu/mulk/demos/blog/authors/BasicCredentials.java
similarity index 85%
rename from blog/src/main/java/eu/mulk/demos/blog/BasicCredentials.java
rename to blog/src/main/java/eu/mulk/demos/blog/authors/BasicCredentials.java
index 01471c6..92e7a37 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/BasicCredentials.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/authors/BasicCredentials.java
@@ -1,6 +1,7 @@
-package eu.mulk.demos.blog;
+package eu.mulk.demos.blog.authors;
 
 import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
 import javax.persistence.MapsId;
@@ -11,6 +12,7 @@
 
   @OneToOne(fetch = FetchType.LAZY)
   @MapsId
+  @JsonbTransient
   public Author author;
 
   public String username;
diff --git a/blog/src/main/java/eu/mulk/demos/blog/Comment.java b/blog/src/main/java/eu/mulk/demos/blog/comments/Comment.java
similarity index 67%
rename from blog/src/main/java/eu/mulk/demos/blog/Comment.java
rename to blog/src/main/java/eu/mulk/demos/blog/comments/Comment.java
index 251d6d8..415ac7b 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/Comment.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/comments/Comment.java
@@ -1,10 +1,14 @@
-package eu.mulk.demos.blog;
+package eu.mulk.demos.blog.comments;
 
+import eu.mulk.demos.blog.posts.Post;
 import io.quarkus.hibernate.orm.panache.PanacheEntity;
 import java.time.Instant;
 import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Column;
 import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.FetchType;
 import javax.persistence.ManyToOne;
 
 @Entity
@@ -14,11 +18,14 @@
 
   public Instant publicationDate;
 
+  @Enumerated(EnumType.STRING)
+  public SpamStatus spamStatus;
+
   @Column(columnDefinition = "TEXT")
   public String text;
 
+  @ManyToOne(fetch = FetchType.LAZY)
   @JsonbTransient
-  @ManyToOne
   public Post post;
 
   public static Comment create(Post post, String authorName, String text) {
@@ -27,6 +34,8 @@
     c.publicationDate = Instant.now();
     c.text = text;
     c.post = post;
+    c.spamStatus = SpamStatus.UNKNOWN;
     return c;
   }
+
 }
diff --git a/blog/src/main/java/eu/mulk/demos/blog/comments/SpamAssessmentService.java b/blog/src/main/java/eu/mulk/demos/blog/comments/SpamAssessmentService.java
new file mode 100644
index 0000000..efa9ac2
--- /dev/null
+++ b/blog/src/main/java/eu/mulk/demos/blog/comments/SpamAssessmentService.java
@@ -0,0 +1,33 @@
+package eu.mulk.demos.blog.comments;
+
+import static java.util.stream.Collectors.toMap;
+
+import java.util.Collection;
+import java.util.Map;
+import javax.enterprise.context.ApplicationScoped;
+
+/**
+ * Simulates a remote service that classifies {@link Comment}s as either {@link SpamStatus#SPAM} or
+ * {@link SpamStatus#HAM}.
+ */
+@ApplicationScoped
+public class SpamAssessmentService {
+
+  /**
+   * Classifies a list of {@link Comment}s as either {@link SpamStatus#SPAM} or * {@link
+   * SpamStatus#HAM}.
+   *
+   * @return a map mapping {@link Comment#id}s to {@link SpamStatus}es.
+   */
+  public Map<Long, SpamStatus> assess(Collection<Comment> comments) {
+    return comments.stream().collect(toMap(x -> x.id, this::assessOne));
+  }
+
+  private SpamStatus assessOne(Comment c) {
+    if (c.authorName.startsWith("Anonymous")) {
+      return SpamStatus.SPAM;
+    }
+
+    return SpamStatus.HAM;
+  }
+}
diff --git a/blog/src/main/java/eu/mulk/demos/blog/comments/SpamStatus.java b/blog/src/main/java/eu/mulk/demos/blog/comments/SpamStatus.java
new file mode 100644
index 0000000..a5cd7ee
--- /dev/null
+++ b/blog/src/main/java/eu/mulk/demos/blog/comments/SpamStatus.java
@@ -0,0 +1,7 @@
+package eu.mulk.demos.blog.comments;
+
+public enum SpamStatus {
+  UNKNOWN,
+  HAM,
+  SPAM,
+}
diff --git a/blog/src/main/java/eu/mulk/demos/blog/Category.java b/blog/src/main/java/eu/mulk/demos/blog/posts/Category.java
similarity index 89%
rename from blog/src/main/java/eu/mulk/demos/blog/Category.java
rename to blog/src/main/java/eu/mulk/demos/blog/posts/Category.java
index 0959d18..5870010 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/Category.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/posts/Category.java
@@ -1,4 +1,4 @@
-package eu.mulk.demos.blog;
+package eu.mulk.demos.blog.posts;
 
 import io.quarkus.hibernate.orm.panache.PanacheEntity;
 import javax.persistence.Entity;
diff --git a/blog/src/main/java/eu/mulk/demos/blog/Post.java b/blog/src/main/java/eu/mulk/demos/blog/posts/Post.java
similarity index 72%
rename from blog/src/main/java/eu/mulk/demos/blog/Post.java
rename to blog/src/main/java/eu/mulk/demos/blog/posts/Post.java
index 3ae3fa0..6fb2c11 100644
--- a/blog/src/main/java/eu/mulk/demos/blog/Post.java
+++ b/blog/src/main/java/eu/mulk/demos/blog/posts/Post.java
@@ -1,8 +1,11 @@
-package eu.mulk.demos.blog;
+package eu.mulk.demos.blog.posts;
 
+import eu.mulk.demos.blog.authors.Author;
+import eu.mulk.demos.blog.comments.Comment;
 import io.quarkus.hibernate.orm.panache.PanacheEntity;
 import java.time.Instant;
-import java.util.List;
+import java.util.Set;
+import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -21,13 +24,16 @@
   public String body;
 
   @ManyToOne(fetch = FetchType.LAZY)
+  @JsonbTransient
   public Author author;
 
   @ManyToMany(fetch = FetchType.LAZY)
-  public List<Category> categories;
+  @JsonbTransient
+  public Set<Category> categories;
 
   @OneToMany(fetch = FetchType.LAZY, mappedBy = "post")
-  public List<Comment> comments;
+  @JsonbTransient
+  public Set<Comment> comments;
 
   public static Post create(Author author, String title) {
     var p = new Post();
diff --git a/blog/src/main/java/eu/mulk/demos/blog/posts/PostResource.java b/blog/src/main/java/eu/mulk/demos/blog/posts/PostResource.java
new file mode 100644
index 0000000..ae8b4de
--- /dev/null
+++ b/blog/src/main/java/eu/mulk/demos/blog/posts/PostResource.java
@@ -0,0 +1,248 @@
+package eu.mulk.demos.blog.posts;
+
+import eu.mulk.demos.blog.authors.Author;
+import eu.mulk.demos.blog.comments.Comment;
+import eu.mulk.demos.blog.comments.SpamAssessmentService;
+import eu.mulk.demos.blog.comments.SpamStatus;
+import java.util.List;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import javax.transaction.Transactional;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.hibernate.annotations.LazyToOne;
+import org.hibernate.annotations.LazyToOneOption;
+import org.jboss.logging.Logger;
+
+@Path("/posts")
+@Produces(MediaType.APPLICATION_JSON)
+public class PostResource {
+
+  static final Logger log = Logger.getLogger(PostResource.class);
+
+  @Inject
+  SpamAssessmentService spamAssessmentService;
+
+  @PersistenceContext
+  EntityManager entityManager;
+
+  /**
+   * Fetches all posts with no extra information.
+   *
+   * Simple.  No surprises.
+   */
+  @GET
+  @Transactional
+  public List<Post> getAll() {
+    clearLog();
+
+    return Post.findAll().list();
+  }
+
+  /**
+   * Fetches all posts with comments included.
+   *
+   * Lazy fetching.  Simple.  No surprises.
+   */
+  @GET
+  @Transactional
+  @Path("/q1")
+  public List<Post> getAllWithComments() {
+    clearLog();
+
+    return Post.find(
+        """
+            SELECT p FROM Post p
+              LEFT JOIN FETCH p.comments 
+            """)
+        .list();
+  }
+
+  /**
+   * Fetches all posts with author info included.
+   *
+   * <strong>Oops!</strong>
+   *
+   * {@link LazyToOne} with {@link LazyToOneOption#NO_PROXY} on {@link Author#basicCredentials} is
+   * needed to make this efficient.
+   */
+  @GET
+  @Transactional
+  @Path("/q2")
+  public List<Post> getAllWithAuthors() {
+    clearLog();
+
+    return Post.find(
+        """
+            SELECT p FROM Post p
+              LEFT JOIN FETCH p.author 
+            """)
+        .list();
+  }
+
+  /**
+   * Fetches all posts with comments and category info included.
+   *
+   * <strong>Oops!</strong>  Crashes.
+   *
+   * Either use {@link Set} and get bad performance or do it as in {@link
+   * #getAllWithCommentsAndCategories2()}.
+   */
+  @GET
+  @Transactional
+  @Path("/q3")
+  public List<Post> getAllWithCommentsAndCategories() {
+    clearLog();
+
+    return Post.find(
+        """
+            SELECT p FROM Post p
+              LEFT JOIN FETCH p.comments
+              LEFT JOIN FETCH p.categories
+            """)
+        .list();
+  }
+
+  /**
+   * Fetches all posts with comments and category info included.
+   *
+   * 2 queries, but hey, no cartesian explosion!  Works really well.
+   */
+  @GET
+  @Transactional
+  @Path("/q4")
+  public List<Post> getAllWithCommentsAndCategories2() {
+    clearLog();
+
+    List<Post> posts = Post.find(
+        """
+            SELECT p FROM Post p
+              LEFT JOIN FETCH p.comments
+            """)
+        .list();
+
+    posts = Post.find(
+        """
+            SELECT DISTINCT p FROM Post p
+              LEFT JOIN FETCH p.categories
+             WHERE p IN (?1)
+            """,
+        posts)
+        .list();
+
+    return posts;
+  }
+
+  /**
+   * Fetches all posts with comments and category info included.
+   *
+   * 2 queries, but hey, no cartesian explosion!  Works really well.
+   */
+  @POST
+  @Transactional
+  @Path("/q5")
+  public void updateCommentStatus() {
+    clearLog();
+
+    List<Comment> comments = Comment.find(
+        """
+            SELECT c FROM Comment c
+             WHERE c.spamStatus = 'UNKNOWN'
+            """)
+        .list();
+
+    var assessments = spamAssessmentService.assess(comments);
+
+    for (var assessment : assessments.entrySet()) {
+      Comment comment = Comment.findById(assessment.getKey());
+      comment.spamStatus = assessment.getValue();
+    }
+  }
+
+  /**
+   * Resets the {@link Comment#spamStatus} to {@link SpamStatus#UNKNOWN} on all comments.
+   *
+   * This issues a lot of UPDATE statements, but semantically speaking it works.
+   */
+  @POST
+  @Transactional
+  @Path("/q6")
+  public void resetCommentStatus() {
+    clearLog();
+
+    List<Comment> comments = Comment.find(
+        """
+            SELECT c FROM Comment c
+             WHERE c.spamStatus <> 'UNKNOWN'
+            """)
+        .list();
+    comments.forEach(c -> c.spamStatus = SpamStatus.UNKNOWN);
+  }
+
+  /**
+   * Resets the {@link Comment#spamStatus} to {@link SpamStatus#UNKNOWN} on all comments.
+   *
+   * This is exactly equivalent to {@link #resetCommentStatus()} and just as efficient or
+   * inefficient.
+   */
+  @POST
+  @Transactional
+  @Path("/q6")
+  public void resetCommentStatus2() {
+    clearLog();
+
+    Comment.update("UPDATE Comment c SET c.spamStatus = 'UNKNOWN' WHERE c.spamStatus <> 'UNKNOWN'");
+  }
+
+  /**
+   * Resets the {@link Comment#spamStatus} to {@link SpamStatus#UNKNOWN} on all comments.
+   *
+   * This is exactly equivalent to {@link #resetCommentStatus()} and just as efficient or
+   * inefficient.
+   */
+  @POST
+  @Transactional
+  @Path("/q7")
+  public void resetCommentStatus3() {
+    clearLog();
+
+    entityManager.createNativeQuery(
+        "UPDATE comment SET spam_status = 'UNKNOWN' WHERE spam_status <> 'UNKNOWN'")
+        .executeUpdate();
+  }
+
+  /**
+   * Fetches all posts with all the relevant info for an overview included.
+   */
+  @GET
+  @Transactional
+  @Path("/q8")
+  @Produces(MediaType.APPLICATION_JSON)
+  public List<PostSummary> overview() {
+    clearLog();
+
+    return entityManager.createQuery(
+        """
+            SELECT NEW eu.mulk.demos.blog.posts.PostSummary(
+                p.author.name, p.title, p.publicationDate, size(p.comments))
+              FROM Post p
+            """,
+        PostSummary.class)
+        .getResultList();
+  }
+
+  private static void clearLog() {
+    log.infof("""
+                
+        -----------------------------------------------------
+        -------------------- NEW REQUEST --------------------
+        -----------------------------------------------------
+        """);
+  }
+
+}
diff --git a/blog/src/main/java/eu/mulk/demos/blog/posts/PostSummary.java b/blog/src/main/java/eu/mulk/demos/blog/posts/PostSummary.java
new file mode 100644
index 0000000..f6ad3ec
--- /dev/null
+++ b/blog/src/main/java/eu/mulk/demos/blog/posts/PostSummary.java
@@ -0,0 +1,22 @@
+package eu.mulk.demos.blog.posts;
+
+import java.time.Instant;
+
+public final class PostSummary {
+
+  public final String authorName;
+  public final String title;
+  public final Instant publicationDate;
+  public final int commentCount;
+
+  public PostSummary(
+      String authorName,
+      String title,
+      Instant publicationDate,
+      int commentCount) {
+    this.authorName = authorName;
+    this.title = title;
+    this.publicationDate = publicationDate;
+    this.commentCount = commentCount;
+  }
+}
diff --git a/blog/src/main/resources/application.properties b/blog/src/main/resources/application.properties
index fbf150e..e8b4aaf 100644
--- a/blog/src/main/resources/application.properties
+++ b/blog/src/main/resources/application.properties
@@ -5,9 +5,15 @@
 %dev.quarkus.datasource.username = demo
 %dev.quarkus.datasource.password =
 
+quarkus.hibernate-orm.physical-naming-strategy = com.vladmihalcea.hibernate.type.util.CamelCaseToSnakeCaseNamingStrategy
+quarkus.hibernate-orm.dialect = io.quarkus.hibernate.orm.runtime.dialect.QuarkusPostgreSQL10Dialect
+
 %dev.quarkus.hibernate-orm.log.sql = true
 %dev.quarkus.hibernate-orm.log.format-sql = true
 %dev.quarkus.hibernate-orm.database.generation = drop-and-create
+%dev.quarkus.hibernate-orm.database.generation.create-schemas = true
 
-quarkus.hibernate-orm.physical-naming-strategy = com.vladmihalcea.hibernate.type.util.CamelCaseToSnakeCaseNamingStrategy
-quarkus.hibernate-orm.dialect = io.quarkus.hibernate.orm.runtime.dialect.QuarkusPostgreSQL10Dialect
+quarkus.hibernate-orm.jdbc.statement-batch-size = 200
+quarkus.hibernate-orm.jdbc.statement-fetch-size = 1000
+quarkus.hibernate-orm.fetch.batch-size = 200
+quarkus.hibernate-orm.fetch.max-depth = 5