Archived
Move search and the front-page decisions out of the browser into Java
Search downloaded every post to every visitor and filtered the list with String.includes, so it could only ever match what a summary carries — a phrase from the body of a post was unfindable, and the cost grew with each post published. It is now a query (/api/posts/search) that searches the body too. The home page picked 'the latest post' by taking element zero of that list, which was only correct for as long as the API happened to return posts in that order. /api/home now decides what leads, what counts as recent, and the tag counts, in one call. Tag counting moves out of the controller into TagService, shared by both. The LIKE escaping needed an explicit ESCAPE clause: there is no default escape character to rely on, and posts are markdown full of literal backslashes — escaping with a backslash turned a search for '%' into a search for backslashes and matched unrelated posts. Caught by the new tests. Also on platform 0.1.6 (contact-form header-injection fix).
This commit is contained in:
@@ -29,4 +29,19 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
/** [tag, count] for every tag used by a published post. */
|
||||
@Query("select t, count(p) from Post p join p.tags t where p.published = true group by t order by t")
|
||||
List<Object[]> tagCounts();
|
||||
|
||||
/**
|
||||
* Full-text-ish search across everything a reader would expect to match — including the post BODY,
|
||||
* which the old browser-side search could not reach because it only ever had the summaries.
|
||||
*
|
||||
* <p>{@code distinct} because the tag join multiplies rows for a post matching on several tags.
|
||||
*/
|
||||
@Query("select distinct p from Post p left join p.tags t where p.published = true and ("
|
||||
+ "lower(p.title) like :q escape '#' or lower(p.excerpt) like :q escape '#' "
|
||||
+ "or lower(p.author) like :q escape '#' or lower(p.content) like :q escape '#' "
|
||||
+ "or lower(t) like :q escape '#') "
|
||||
+ "order by p.publishedOn desc, p.id desc")
|
||||
List<Post> search(@Param("q") String lowercaseLikePattern);
|
||||
|
||||
List<Post> findByPublishedTrueOrderByPublishedOnDescIdDesc(org.springframework.data.domain.Limit limit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.reformedwitness.cog.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import net.reformedwitness.cog.domain.Post;
|
||||
import net.reformedwitness.cog.repo.PostRepository;
|
||||
|
||||
/**
|
||||
* Site search.
|
||||
*
|
||||
* <p>This used to be done in the browser: every visitor downloaded the whole post list and filtered
|
||||
* it with {@code String.includes}. That could only ever match what the summary carried — title,
|
||||
* excerpt, author, tags — so searching for a phrase from the body of a post found nothing, and the
|
||||
* cost grew with every post published.
|
||||
*/
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
/** Below this a search matches most of the site; treat it as no search at all. */
|
||||
private static final int MIN_TERM_LENGTH = 2;
|
||||
|
||||
private final PostRepository posts;
|
||||
|
||||
public SearchService(PostRepository posts) {
|
||||
this.posts = posts;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Post> search(String query) {
|
||||
String term = query == null ? "" : query.trim();
|
||||
if (term.length() < MIN_TERM_LENGTH) {
|
||||
return List.of();
|
||||
}
|
||||
return posts.search("%" + escapeLike(term.toLowerCase()) + "%");
|
||||
}
|
||||
|
||||
/**
|
||||
* A search for "100%" must look for a literal percent sign, not "anything".
|
||||
*
|
||||
* <p>Uses '#' with an explicit {@code ESCAPE} clause on the query rather than the backslash you'd
|
||||
* expect: LIKE has no default escape character to rely on here, and the posts are markdown, which
|
||||
* is full of literal backslashes — escaping with one turned a search for "%" into a search for
|
||||
* backslashes and matched unrelated posts.
|
||||
*/
|
||||
private static String escapeLike(String raw) {
|
||||
// The escape character itself first, or the escapes below get double-escaped.
|
||||
return raw.replace("#", "##").replace("%", "#%").replace("_", "#_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.reformedwitness.cog.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import net.reformedwitness.cog.repo.PostRepository;
|
||||
|
||||
/** Tag counts across published posts — used by the tag page and by the home sidebar. */
|
||||
@Service
|
||||
public class TagService {
|
||||
|
||||
/** tag -> how many published posts carry it. */
|
||||
public record TagCount(String tag, long count) {}
|
||||
|
||||
private final PostRepository posts;
|
||||
|
||||
public TagService(PostRepository posts) {
|
||||
this.posts = posts;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TagCount> counts() {
|
||||
return posts.tagCounts().stream()
|
||||
.map(row -> new TagCount((String) row[0], ((Number) row[1]).longValue()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.reformedwitness.cog.web;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Limit;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import net.reformedwitness.cog.domain.Post;
|
||||
import net.reformedwitness.cog.repo.PostRepository;
|
||||
import net.reformedwitness.cog.service.TagService;
|
||||
|
||||
/**
|
||||
* Everything the front page needs, in one call.
|
||||
*
|
||||
* <p>Which post is "the latest" was previously decided in the browser by taking element zero of the
|
||||
* list — correct only for as long as the API happened to return posts in that order, and invisible
|
||||
* from the backend if it ever changed. It's a decision, so it's made here.
|
||||
*/
|
||||
@RestController
|
||||
public class HomeController {
|
||||
|
||||
/** How many posts the sidebar lists. */
|
||||
private static final Limit RECENT = Limit.of(5);
|
||||
|
||||
private final PostRepository posts;
|
||||
private final TagService tags;
|
||||
|
||||
public HomeController(PostRepository posts, TagService tags) {
|
||||
this.posts = posts;
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param featured the post to lead with, null when nothing is published yet
|
||||
* @param posts the rest of the published posts, newest first, excluding the featured one
|
||||
*/
|
||||
public record HomePage(Dto.PostSummary featured, List<Dto.PostSummary> posts,
|
||||
List<Dto.PostSummary> recent, List<Dto.TagCount> tags) {}
|
||||
|
||||
@GetMapping("/api/home")
|
||||
public HomePage home() {
|
||||
List<Post> published = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc();
|
||||
Dto.PostSummary featured = published.isEmpty() ? null : Dto.summary(published.getFirst());
|
||||
List<Dto.PostSummary> rest = published.stream().skip(1).map(Dto::summary).toList();
|
||||
List<Dto.PostSummary> recent = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc(RECENT)
|
||||
.stream().map(Dto::summary).toList();
|
||||
return new HomePage(featured, rest, recent,
|
||||
tags.counts().stream().map(t -> new Dto.TagCount(t.tag(), t.count())).toList());
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,18 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import net.reformedwitness.cog.domain.Post;
|
||||
import net.reformedwitness.cog.repo.PostRepository;
|
||||
import net.reformedwitness.cog.service.SearchService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
public class PostController {
|
||||
|
||||
private final PostRepository posts;
|
||||
private final SearchService search;
|
||||
|
||||
public PostController(PostRepository posts) {
|
||||
public PostController(PostRepository posts, SearchService search) {
|
||||
this.posts = posts;
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -37,6 +40,15 @@ public class PostController {
|
||||
return result.stream().map(Dto::summary).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param q what the reader typed; a term shorter than two characters returns nothing rather than
|
||||
* the whole site
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
public List<Dto.PostSummary> search(@RequestParam(required = false, defaultValue = "") String q) {
|
||||
return search.search(q).stream().map(Dto::summary).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}")
|
||||
public Dto.PostDetail get(@PathVariable String slug) {
|
||||
Post p = posts.findBySlug(slug)
|
||||
|
||||
@@ -6,23 +6,21 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import net.reformedwitness.cog.repo.PostRepository;
|
||||
import net.reformedwitness.cog.service.TagService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tags")
|
||||
public class TagController {
|
||||
|
||||
private final PostRepository posts;
|
||||
private final TagService tags;
|
||||
|
||||
public TagController(PostRepository posts) {
|
||||
this.posts = posts;
|
||||
public TagController(TagService tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
/** All tags used by published posts, with counts. */
|
||||
@GetMapping
|
||||
public List<Dto.TagCount> list() {
|
||||
return posts.tagCounts().stream()
|
||||
.map(row -> new Dto.TagCount((String) row[0], ((Number) row[1]).longValue()))
|
||||
.toList();
|
||||
return tags.counts().stream().map(t -> new Dto.TagCount(t.tag(), t.count())).toList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user