diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a37cd90..50ab6bc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -20,6 +20,19 @@ export function getPosts(params: { tag?: string; author?: string } = {}): Promis return api.get(`/posts${q ? `?${q}` : ''}`); } export const getPost = (slug: string) => api.get(`/posts/${encodeURIComponent(slug)}`); + +/** Searching is done by the database, over post bodies too — not by filtering a downloaded list. */ +export const searchPosts = (q: string) => + api.get(`/posts/search?q=${encodeURIComponent(q)}`); + +export interface HomePage { + featured: PostSummary | null; + posts: PostSummary[]; + recent: PostSummary[]; + tags: TagCount[]; +} +/** One call for the whole front page; which post leads is the backend's decision. */ +export const getHome = () => api.get('/home'); export const getTags = () => api.get('/tags'); export const getAuthors = () => api.get('/authors'); export const getAuthor = (name: string) => api.get(`/authors/${encodeURIComponent(name)}`); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 2eec8ef..df23eff 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,14 +1,14 @@ import { Link } from 'react-router-dom'; -import { getPosts, getTags } from '../api'; +import { getHome } from '../api'; import { useAsync } from '../lib/useAsync'; import PostCard from '../components/PostCard'; import Sidebar from '../components/Sidebar'; export default function HomePage() { - const { data: posts, loading } = useAsync(() => getPosts(), []); - const { data: tags } = useAsync(() => getTags(), []); - const list = posts ?? []; - const featured = list[0]; + // One call. Which post leads, what counts as recent, and the tag counts are all decided by the + // backend — this page used to take element zero of the post list and call it "the latest". + const { data, loading } = useAsync(() => getHome(), []); + const featured = data?.featured ?? null; return (
@@ -53,9 +53,9 @@ export default function HomePage() {

Recent Posts

{loading &&

Loading…

}
- {list.slice(1).map((post) => )} + {(data?.posts ?? []).map((post) => )}
- {!loading && list.length === 0 &&

No posts yet.

} + {!loading && !featured &&

No posts yet.

}
@@ -64,7 +64,7 @@ export default function HomePage() {
- +
); diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 61bcb3c..91eef9e 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { getPosts } from '../api'; +import { searchPosts } from '../api'; import { useAsync } from '../lib/useAsync'; import PostCard from '../components/PostCard'; @@ -8,15 +8,11 @@ export default function SearchPage() { const [params, setParams] = useSearchParams(); const q = params.get('q') ?? ''; const [term, setTerm] = useState(q); - const { data: posts, loading } = useAsync(() => getPosts(), []); - const needle = q.trim().toLowerCase(); - const results = (posts ?? []).filter((p) => - !needle - || p.title.toLowerCase().includes(needle) - || p.excerpt.toLowerCase().includes(needle) - || p.author.toLowerCase().includes(needle) - || p.tags.some((t) => t.toLowerCase().includes(needle))); + // The database does the matching — including post bodies, which the old browser-side filter + // never had, and without shipping every post to every visitor who opens this page. + const { data: results, loading } = useAsync(() => searchPosts(q), [q]); + const found = results ?? []; return (
@@ -36,13 +32,13 @@ export default function SearchPage() { {loading &&

Loading…

} - {!loading && needle && ( + {!loading && q.trim() && (

- {results.length} {results.length === 1 ? 'result' : 'results'} for “{q}” + {found.length} {found.length === 1 ? 'result' : 'results'} for “{q}”

)}
- {results.map((post) => )} + {found.map((post) => )}
); diff --git a/pom.xml b/pom.xml index b186473..03cdd15 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ net.thebennett.platform platform-parent - 0.1.5 + 0.1.6 @@ -26,7 +26,7 @@ net.thebennett.platform platform-bom - 0.1.5 + 0.1.6 pom import diff --git a/src/main/java/net/reformedwitness/cog/repo/PostRepository.java b/src/main/java/net/reformedwitness/cog/repo/PostRepository.java index 31797f5..834f5b0 100644 --- a/src/main/java/net/reformedwitness/cog/repo/PostRepository.java +++ b/src/main/java/net/reformedwitness/cog/repo/PostRepository.java @@ -29,4 +29,19 @@ public interface PostRepository extends JpaRepository { /** [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 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. + * + *

{@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 search(@Param("q") String lowercaseLikePattern); + + List findByPublishedTrueOrderByPublishedOnDescIdDesc(org.springframework.data.domain.Limit limit); } diff --git a/src/main/java/net/reformedwitness/cog/service/SearchService.java b/src/main/java/net/reformedwitness/cog/service/SearchService.java new file mode 100644 index 0000000..26c20cf --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/service/SearchService.java @@ -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. + * + *

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 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". + * + *

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("_", "#_"); + } +} diff --git a/src/main/java/net/reformedwitness/cog/service/TagService.java b/src/main/java/net/reformedwitness/cog/service/TagService.java new file mode 100644 index 0000000..16568c0 --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/service/TagService.java @@ -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 counts() { + return posts.tagCounts().stream() + .map(row -> new TagCount((String) row[0], ((Number) row[1]).longValue())) + .toList(); + } +} diff --git a/src/main/java/net/reformedwitness/cog/web/HomeController.java b/src/main/java/net/reformedwitness/cog/web/HomeController.java new file mode 100644 index 0000000..0f234a5 --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/web/HomeController.java @@ -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. + * + *

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 posts, + List recent, List tags) {} + + @GetMapping("/api/home") + public HomePage home() { + List published = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc(); + Dto.PostSummary featured = published.isEmpty() ? null : Dto.summary(published.getFirst()); + List rest = published.stream().skip(1).map(Dto::summary).toList(); + List 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()); + } +} diff --git a/src/main/java/net/reformedwitness/cog/web/PostController.java b/src/main/java/net/reformedwitness/cog/web/PostController.java index ae3604c..e4f641b 100644 --- a/src/main/java/net/reformedwitness/cog/web/PostController.java +++ b/src/main/java/net/reformedwitness/cog/web/PostController.java @@ -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 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) diff --git a/src/main/java/net/reformedwitness/cog/web/TagController.java b/src/main/java/net/reformedwitness/cog/web/TagController.java index 687a8e3..71a4678 100644 --- a/src/main/java/net/reformedwitness/cog/web/TagController.java +++ b/src/main/java/net/reformedwitness/cog/web/TagController.java @@ -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 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(); } } diff --git a/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java b/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java new file mode 100644 index 0000000..d7c5fd3 --- /dev/null +++ b/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java @@ -0,0 +1,113 @@ +package net.reformedwitness.cog; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import net.reformedwitness.cog.domain.Post; +import net.reformedwitness.cog.service.SearchService; +import net.reformedwitness.cog.web.HomeController; +import net.reformedwitness.cog.web.PostController; + +/** Search and the front page, against the real seeded content. */ +@SpringBootTest(properties = { + "platform.storage.access-key=test", + "platform.storage.secret-key=test" +}) +@Testcontainers +class SearchAndHomeTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + SearchService search; + + @Autowired + HomeController home; + + @Autowired + PostController postController; + + @Test + void findsAPostByAWordFromItsTitle() { + List hits = search.search("grace"); + assertThat(hits).isNotEmpty(); + } + + @Test + void searchesInsideThePostBodyNotJustTheSummary() { + // The whole point of moving this off the browser: the client only ever had summaries, so a + // phrase from the body of a post was unfindable. + Post seeded = search.search("grace").getFirst(); + String body = seeded.getContent(); + assertThat(body).isNotBlank(); + + // A distinctive long word from the body, that isn't also in the title/excerpt/tags. + String word = java.util.Arrays.stream(body.split("\\W+")) + .filter(w -> w.length() > 9) + .filter(w -> !seeded.getTitle().toLowerCase().contains(w.toLowerCase())) + .filter(w -> seeded.getExcerpt() == null || !seeded.getExcerpt().toLowerCase().contains(w.toLowerCase())) + .findFirst() + .orElse(null); + assertThat(word).as("seeded post should contain a body-only word to search for").isNotNull(); + + assertThat(search.search(word)) + .as("searching for '%s', which appears only in the body", word) + .extracting(Post::getSlug) + .contains(seeded.getSlug()); + } + + @Test + void ashortOrEmptyTermMatchesNothingRatherThanEverything() { + assertThat(search.search("")).isEmpty(); + assertThat(search.search(" ")).isEmpty(); + assertThat(search.search("a")).isEmpty(); + } + + @Test + void wildcardCharactersAreSearchedLiterally() { + int total = home.home().posts().size() + 1; // + the featured one + + // Unescaped, these are LIKE wildcards and would match every published post. + assertThat(search.search("%%")).extracting(Post::getSlug).as("posts matching a literal %%").isEmpty(); + // "__" does legitimately appear — markdown writes bold that way — so the check is that it + // matches those posts and not the whole site. + assertThat(search.search("__")).hasSizeLessThan(total); + } + + @Test + void searchIsCaseInsensitive() { + assertThat(search.search("GRACE")).hasSameSizeAs(search.search("grace")); + } + + @Test + void theHomePageLeadsWithTheNewestPostAndDoesNotRepeatIt() { + HomeController.HomePage page = home.home(); + + assertThat(page.featured()).isNotNull(); + assertThat(page.recent()).isNotEmpty(); + assertThat(page.tags()).isNotEmpty(); + assertThat(page.posts()) + .as("the featured post must not appear again in the list below it") + .extracting(net.reformedwitness.cog.web.Dto.PostSummary::slug) + .doesNotContain(page.featured().slug()); + } + + @Test + void theSearchPathIsNotSwallowedByThePostSlugRoute() { + // /api/posts/search and /api/posts/{slug} overlap; the literal path has to win. + assertThat(postController.search("grace")).isNotEmpty(); + } +}