feat: Tech Log 공개 조회 백엔드 — public-v1 18개 operation 구현

public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제
2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어
public-v1도 18/18이다.

생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias
swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다.
거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고,
validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt,
ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가
사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.

원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고
snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는
alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를
받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다.

- 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가
  실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다
- verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다.
  이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다

계약이 선언했는데 서버가 무시하던 필터를 채웠다
지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다.
- exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음
- listPublicProjectDecisions: status 필터 없음
- listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음
- 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다
- enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다

응답 봉투와 오류 경계
- 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006)
- PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를
  ...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터
  오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는
  public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다
- FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다

실행이 잡아낸 결함
컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다.
- profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다
- latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의
  LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다
- home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를
  required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다.
  HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다

V9__techlog_public_surface.sql
설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config,
profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행
시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다.

게이트
- PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다.
  계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해
  "비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다
- PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조.
  INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로
  고정한다. vendored 계약의 MANIFEST 해시도 확인한다
- postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제
  PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가
  없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를
  함께 심어 어느 경로로도 새지 않는지 확인한다

검증
./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24.
실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종
전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다.

알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아
헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-20 18:34:18 +09:00
co-authored by Claude Opus 5
parent e3254def57
commit 365560efb6
118 changed files with 9160 additions and 44 deletions
@@ -131,6 +131,13 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogPublicPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
@@ -0,0 +1,260 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 공개된 Case / Reference / Question 상세.
*
* <p>본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이
* 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다.
*/
@Repository
public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
private final PublicRelationLookup relations;
public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
this.relations = new PublicRelationLookup(jdbcClient);
}
@Override
public Optional<CaseDetailView> findCase(String slug) {
return document("CASE", slug)
.map(
row ->
new CaseDetailView(
row.view().canonicalPath(),
true,
row.view(),
new CaseRelationsView(
relations.firstTargetOfType("CASE", row.id(), "QUESTION"),
relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"),
// 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다.
relations.sourcesOfType(row.id(), "REFERENCE"),
relations.targetsOfType("CASE", row.id(), "CASE"))));
}
@Override
public Optional<ReferenceDetailView> findReference(String slug) {
return document("REFERENCE", slug)
.map(
row ->
new ReferenceDetailView(
row.view().canonicalPath(),
true,
row.view(),
new ReferenceRelationsView(
relations.targetsOfType("REFERENCE", row.id(), "CASE"),
relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"),
relations.targetsOfType("REFERENCE", row.id(), "REFERENCE"))));
}
/** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */
private record DocumentRow(UUID id, PublishedDocumentView view) {}
private Optional<DocumentRow> document(String type, String slug) {
return jdbcClient
.sql(
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
+ " d.content_format_version, d.cover_asset_id,"
+ " c.problem_summary, c.conclusion_summary, c.environment_items,"
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
+ " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug,"
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
+ " a.width AS cover_width, a.height AS cover_height"
+ " FROM document d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " LEFT JOIN topic t ON t.id = d.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " LEFT JOIN asset a ON a.id = d.cover_asset_id"
+ " WHERE d.document_type = :type AND d.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("type", type)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
boolean isCase = "CASE".equals(type);
return new DocumentRow(
id,
new PublishedDocumentView(
type,
rs.getString("navigation_path"),
rs.getString("title"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? json.strings(rs.getString("environment_items")) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
rs.getInt("content_format_version"),
topic(rs),
tags(id),
project(rs),
cover(rs),
instant(rs, "published_at"),
instant(rs, "updated_at"),
instant(rs, "last_verified_at")));
})
.optional();
}
@Override
public Optional<QuestionDetailView> findQuestion(String slug) {
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.context_markdown,"
+ " q.importance_markdown, q.question_status, q.next_verification,"
+ " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at,"
+ " p.navigation_path, p.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
PublishedQuestionView question =
new PublishedQuestionView(
rs.getString("question"),
rs.getString("summary"),
rs.getString("context_markdown"),
rs.getString("importance_markdown"),
rs.getString("question_status"),
rs.getString("next_verification"),
new QuestionPointGroupView(
points(id, "FACT"),
points(id, "ASSUMPTION"),
points(id, "UNKNOWN"),
points(id, "CONSTRAINT")),
updates(id),
rs.getString("resolution_type"),
rs.getString("resolution_summary"),
instant(rs, "resolved_at"),
instant(rs, "opened_at"),
instant(rs, "updated_at"));
return new QuestionDetailView(
rs.getString("navigation_path"),
true,
question,
new QuestionRelationsView(
relations.primaryProject("project_question_link", "question_id", id),
relations.firstTargetOfType("QUESTION", id, "CASE"),
relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"),
relations.targetsOfType("QUESTION", id, "REFERENCE")));
})
.optional();
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
/** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */
private List<QuestionUpdateView> updates(UUID questionId) {
return jdbcClient
.sql(
"SELECT update_type, title, body_markdown, occurred_at FROM question_update"
+ " WHERE question_id = :id AND update_visibility = 'PUBLIC'"
+ " ORDER BY sequence_no")
.param("id", questionId)
.query(
(rs, rowNum) ->
new QuestionUpdateView(
rs.getString("update_type"),
rs.getString("title"),
rs.getString("body_markdown"),
instant(rs, "occurred_at")))
.list();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
var value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
static TopicSummaryView topic(ResultSet rs) throws SQLException {
return rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug"));
}
static ProjectSummaryView project(ResultSet rs) throws SQLException {
return rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug"));
}
static AssetReferenceView cover(ResultSet rs) throws SQLException {
UUID assetId = rs.getObject("cover_asset_id", UUID.class);
return assetId == null
? null
: new AssetReferenceView(
assetId,
"/media/" + assetId,
rs.getString("cover_alt"),
(Integer) rs.getObject("cover_width"),
(Integer) rs.getObject("cover_height"),
rs.getString("cover_content_type"));
}
List<TagSummaryView> tags(UUID documentId) {
return jdbcClient
.sql(
"SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id"
+ " WHERE dt.document_id = :id ORDER BY dt.display_order")
.param("id", documentId)
.query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -0,0 +1,226 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 탐색 목록.
*
* <p>필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다.
*/
@Repository
public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
StringBuilder where =
new StringBuilder(
" WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
if (query.year() != null) {
where.append(" AND date_part('year', p.published_at) = :year");
params.put("year", query.year());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<KnowledgeListItemView> items =
page(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code,"
+ " p.published_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ knowledgeOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
JdbcPublicExploreQueryAdapter::readKnowledge);
return new KnowledgePageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */
private static String knowledgeOrder(String sort) {
String key =
switch (sort == null ? "PUBLISHED_DESC" : sort) {
case "UPDATED_DESC" -> "p.updated_at DESC";
case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST";
default -> "p.published_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
/**
* 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어
* 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다.
*/
private static String questionOrder(String sort) {
String key =
switch (sort == null ? "UPDATED_DESC" : sort) {
case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST";
case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST";
default -> "p.updated_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException {
return new KnowledgeListItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
rs.getString("summary"),
null,
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("last_verified_at") == null
? null
: rs.getTimestamp("last_verified_at").toInstant(),
rs.getString("state_code"));
}
@Override
public QuestionPageView questions(ExploreQuestionsQuery query) {
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'");
Map<String, Object> params = new HashMap<>();
if (query.status() != null) {
where.append(" AND p.state_code = :status");
params.put("status", query.status());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
String joins =
" FROM public_resource_projection p"
+ " JOIN open_question q ON q.id = p.resource_id"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<QuestionListItemView> items =
page(
"SELECT q.question, p.navigation_path, q.question_status, p.summary,"
+ " q.next_verification, p.updated_at,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ questionOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
(rs, rowNum) ->
new QuestionListItemView(
rs.getString("question"),
rs.getString("navigation_path"),
rs.getString("question_status"),
rs.getString("summary"),
null,
rs.getString("next_verification"),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("updated_at").toInstant()));
return new QuestionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
private long count(String fromAndWhere, Map<String, Object> params) {
var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere);
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.query(Long.class).single();
}
private <T> List<T> page(
String sql,
Map<String, Object> params,
int size,
int offset,
org.springframework.jdbc.core.RowMapper<T> mapper) {
var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.param("size", size).param("offset", offset).query(mapper).list();
}
}
@@ -0,0 +1,330 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 프로젝트 목록·상세와 그 하위 목록. */
@Repository
public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ProjectListItemView> list() {
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC")
.query(
(rs, rowNum) ->
new ProjectListItemView(
rs.getString("name"),
rs.getString("slug"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")))
.list();
}
@Override
public Optional<ProjectDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown,"
+ " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step,"
+ " pr.system_overview_markdown, pr.technology_labels,"
+ " p.navigation_path, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID projectId = rs.getObject("id", UUID.class);
PublishedProjectView project =
new PublishedProjectView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("purpose_markdown"),
rs.getString("boundary_markdown"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getString("system_overview_markdown"),
json.strings(rs.getString("technology_labels")),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
return new ProjectDetailView(
rs.getString("navigation_path"),
true,
project,
featuredDecision(projectId),
activeQuestion(projectId),
selectedRecords(projectId));
})
.optional();
}
private RelatedEntryView featuredDecision(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private RelatedEntryView activeQuestion(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_question_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id"
+ " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private List<RelatedEntryView> selectedRecords(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit")
.param("projectId", projectId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
@Override
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다.
String from =
" FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ (query.status() == null ? "" : " AND d.decision_status = :status");
long total =
bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status())
.query(Long.class)
.single();
List<ProjectDecisionItemView> items =
bind(
jdbcClient.sql(
"SELECT d.id, d.statement, d.decision_status, d.rationale_markdown,"
+ " d.decided_at, d.source_question_id, d.source_case_id"
+ from
+ " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query.status())
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new ProjectDecisionItemView(
rs.getObject("id", UUID.class),
rs.getString("statement"),
rs.getString("decision_status"),
rs.getString("rationale_markdown"),
JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"),
publishedEntry(rs.getObject("source_question_id", UUID.class)),
publishedEntry(rs.getObject("source_case_id", UUID.class))))
.list();
return new ProjectDecisionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */
private RelatedEntryView publishedEntry(UUID resourceId) {
if (resourceId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id = :id AND "
+ PublicSql.ACTIVE)
.param("id", resourceId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
@Override
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약이 세는 record 는 CASE/REFERENCE/QUESTION 세 종류다. type 이 없으면 셋 다 센다.
String from =
" FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId"
+ " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')"
+ " AND "
+ PublicSql.ACTIVE
+ (query.type() == null ? "" : " AND p.resource_type = :type")
+ (query.relation() == null ? "" : " AND l.relation_type = :relation");
long total =
bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query)
.query(Long.class)
.single();
List<RelatedEntryView> items =
bindRecord(
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ from
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
return new ProjectRecordPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
@Override
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
String from =
" FROM project_activity a"
+ " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'";
long total =
jdbcClient
.sql("SELECT count(*)" + from)
.param("projectId", projectId)
.query(Long.class)
.single();
List<ProjectActivityItemView> items =
jdbcClient
.sql(
"SELECT a.activity_type, a.title, a.summary, a.occurred_at,"
+ " a.related_resource_id"
+ from
+ " ORDER BY a.occurred_at DESC, a.id DESC"
+ " LIMIT :size OFFSET :offset")
.param("projectId", projectId)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) -> {
RelatedEntryView related =
publishedEntry(rs.getObject("related_resource_id", UUID.class));
return new ProjectActivityItemView(
rs.getString("activity_type"),
rs.getString("title"),
rs.getString("summary"),
JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"),
related == null ? null : related.path());
})
.list();
return new ProjectActivityPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/**
* optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient 가 "쓰이지 않은 파라미터"로 실패하고,
* 반대면 파라미터 미해결로 실패한다 — 총계와 목록 두 쿼리에서 같은 실수를 두 번 하지 않도록 한 곳에 모은다.
*/
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
String status) {
spec = spec.param("projectId", projectId);
return status == null ? spec : spec.param("status", status);
}
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
ProjectRecordPageQuery query) {
spec = spec.param("projectId", projectId);
if (query.type() != null) {
spec = spec.param("type", query.type());
}
return query.relation() == null ? spec : spec.param("relation", query.relation());
}
/** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */
private Optional<UUID> projectId(String slug) {
return jdbcClient
.sql(
"SELECT pr.id FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(UUID.class)
.optional();
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 릴리스 목록·상세.
*
* <p>릴리스는 {@code public_resource_projection} 을 거치지 않는다 — 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code
* workflow_status} 로 공개 여부를 정하는 기록이다.
*/
@Repository
public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ReleaseListItemView> list() {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types FROM release"
+ " WHERE workflow_status = 'PUBLISHED'"
+ " ORDER BY released_on DESC NULLS LAST, version_label DESC")
.query(
(rs, rowNum) ->
new ReleaseListItemView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
"/releases/" + rs.getString("version_label")))
.list();
}
@Override
public Optional<ReleaseDetailView> findByVersion(String version) {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types, reason_markdown,"
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
+ " verification_markdown, known_limitations_markdown, related_resources"
+ " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'")
.param("version", version)
.query(
(rs, rowNum) ->
new ReleaseDetailView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
rs.getString("reason_markdown"),
rs.getString("changes_markdown"),
rs.getString("user_impact_markdown"),
rs.getString("implementation_impact_markdown"),
rs.getString("verification_markdown"),
rs.getString("known_limitations_markdown"),
relatedRecords(rs.getString("related_resources"))))
.optional();
}
/**
* {@code related_resources} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다.
*/
private List<dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView> relatedRecords(
String relatedResourcesJson) {
List<String> ids = json.strings(relatedResourcesJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 공개 검색.
*
* <p>게시 시 만들어 둔 {@code search_text}(제목 + 요약 + 본문 평문)를 본다. 검색 때 본문을 다시 훑지 않는 이유는 그 평문이 게시 시점에 확정된
* 값이기 때문이다 — 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다.
*/
@Repository
public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort {
/** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */
private static final int SNIPPET_LENGTH = 200;
private final JdbcClient jdbcClient;
public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public SearchResultPageView search(SearchQuery query) {
String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%";
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern");
Map<String, Object> params = new HashMap<>();
params.put("pattern", pattern);
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where);
for (Map.Entry<String, Object> e : params.entrySet()) {
countSpec = countSpec.param(e.getKey(), e.getValue());
}
long total = countSpec.query(Long.class).single();
var spec =
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text,"
+ " p.published_at, p.updated_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
List<SearchResultItemView> items =
spec.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new SearchResultItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
snippet(
rs.getString("body_plain_text"),
rs.getString("summary"),
query.query()),
matchedFields(
query.query(),
rs.getString("title"),
rs.getString("summary"),
rs.getString("body_plain_text")),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("updated_at").toInstant()))
.list();
return new SearchResultPageView(
query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */
private static String snippet(String body, String summary, String term) {
String source = (body == null || body.isBlank()) ? summary : body;
if (source == null || source.isBlank()) {
return "";
}
int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT));
if (at < 0) {
return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH);
}
int from = Math.max(0, at - SNIPPET_LENGTH / 2);
int to = Math.min(source.length(), from + SNIPPET_LENGTH);
return source.substring(from, to);
}
/** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */
private static List<String> matchedFields(
String term, String title, String summary, String body) {
String needle = term.toLowerCase(Locale.ROOT);
List<String> fields = new ArrayList<>();
if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("title");
}
if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("summary");
}
if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("content");
}
return fields;
}
}
@@ -0,0 +1,271 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */
@Repository
public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public Optional<SiteView> site() {
return jdbcClient
.sql(
"SELECT s.brand_title, s.identity_statement, s.operator_display_name,"
+ " s.short_identity, s.contacts, s.avatar_asset_id,"
+ " a.content_type, a.alt_text, a.width, a.height"
+ " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id")
.query(
(rs, rowNum) ->
new SiteView(
rs.getString("brand_title"),
rs.getString("identity_statement"),
rs.getString("operator_display_name"),
rs.getString("short_identity"),
avatar(rs),
"/profile",
json.contacts(rs.getString("contacts"))))
.optional();
}
private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException {
UUID assetId = rs.getObject("avatar_asset_id", UUID.class);
if (assetId == null) {
return null;
}
return new AssetReferenceView(
assetId,
// 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1).
"/media/" + assetId,
rs.getString("alt_text"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("content_type"));
}
@Override
public HomeView home(int latestEntryLimit) {
HomeFocusView focus =
jdbcClient
.sql(
"SELECT default_focus_type, current_project_id, open_question_id,"
+ " recent_decision_id FROM home_focus_config")
.query(
(rs, rowNum) ->
HomeFocusView.resolve(
rs.getString("default_focus_type"),
currentWork(rs.getObject("current_project_id", UUID.class)),
openQuestion(rs.getObject("open_question_id", UUID.class)),
recentDecision(rs.getObject("recent_decision_id", UUID.class))))
.optional()
.orElseGet(() -> HomeFocusView.resolve(null, null, null, null));
return new HomeView(focus, latestEntries(latestEntryLimit));
}
/** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */
private HomeFocusView.CurrentWork currentWork(UUID projectId) {
if (projectId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, pr.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", projectId)
.query(
(rs, rowNum) ->
new HomeFocusView.CurrentWork(
rs.getString("name"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private HomeFocusView.OpenQuestion openQuestion(UUID questionId) {
if (questionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", questionId)
.query(
(rs, rowNum) ->
new HomeFocusView.OpenQuestion(
rs.getString("question"),
"/questions/" + rs.getString("slug"),
rs.getString("summary"),
points(questionId, "FACT"),
points(questionId, "UNKNOWN"),
rs.getString("next_verification"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
private HomeFocusView.RecentDecision recentDecision(UUID decisionId) {
if (decisionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at,"
+ " pr.slug AS project_slug FROM project_decision d"
+ " LEFT JOIN project pr ON pr.id = d.project_id"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", decisionId)
.query(
(rs, rowNum) ->
new HomeFocusView.RecentDecision(
rs.getString("statement"),
PublicSql.pathOf(
"PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")),
rs.getString("rationale_markdown"),
json.strings(rs.getString("consequences")),
rs.getTimestamp("decided_at") == null
? null
: rs.getTimestamp("decided_at").toInstant()))
.optional()
.orElse(null);
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestEntries(int limit) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("limit", limit)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
@Override
public Optional<ProfileView> profile() {
return jdbcClient
.sql(
"SELECT headline, introduction_markdown, working_model, territories,"
+ " selected_evidence, trajectory, contacts FROM profile_page"
+ " WHERE target_visibility = 'PUBLIC'")
.query(
(rs, rowNum) ->
new ProfileView(
rs.getString("headline"),
rs.getString("introduction_markdown"),
json.namedDescriptions(rs.getString("working_model")),
json.territories(rs.getString("territories")),
selectedEvidence(rs.getString("selected_evidence")),
json.namedDescriptions(rs.getString("trajectory")),
json.contacts(rs.getString("contacts"))))
.optional();
}
/**
* {@code selected_evidence} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources}
* 와 같은 규칙).
*/
private List<RelatedEntryView> selectedEvidence(String selectedEvidenceJson) {
List<String> ids = json.strings(selectedEvidenceJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,178 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */
@Repository
public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort {
/** 상세 화면이 한 화면에 담는 개수. */
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<TopicListItemView> list() {
return jdbcClient
.sql(
"SELECT t.name, t.slug, t.description,"
+ " (SELECT count(*) FROM public_resource_projection p"
+ " WHERE p.primary_topic_id = t.id AND "
+ PublicSql.ACTIVE
+ ") AS record_count"
+ " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name")
.query(
(rs, rowNum) ->
new TopicListItemView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getInt("record_count")))
.list();
}
@Override
public Optional<TopicDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'")
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID topicId = rs.getObject("id", UUID.class);
return new TopicDetailView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getString("scope"),
featured(topicId, "START_HERE").stream().findFirst().orElse(null),
featured(topicId, "FEATURED_CASE"),
activeQuestions(topicId),
relatedProjects(topicId),
latestRecords(topicId));
})
.optional();
}
/**
* {@code topic_featured_document} 가 지목한 문서 중 <b>공개된 것만</b> 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와
* 별개다.
*/
private List<RelatedEntryView> featured(UUID topicId, String role) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM topic_featured_document f"
+ " JOIN public_resource_projection p ON p.resource_id = f.document_id"
+ " WHERE f.topic_id = :topicId AND f.feature_role = :role AND "
+ PublicSql.ACTIVE
+ " ORDER BY f.display_order")
.param("topicId", topicId)
.param("role", role)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> activeQuestions(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId"
+ " AND p.state_code <> 'RESOLVED' AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> relatedProjects(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_topic pt"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id"
+ " WHERE pt.topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " ORDER BY pt.display_order")
.param("topicId", topicId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestRecords(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE p.primary_topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new RelatedEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"));
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회가 읽는 jsonb 컬럼을 푼다.
*
* <p>Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이
* 바뀌면 이미 저장된 행을 못 읽게 된다.
*/
final class PublicJson {
private final ObjectMapper mapper;
PublicJson(ObjectMapper mapper) {
this.mapper = mapper;
}
List<String> strings(String json) {
List<String> out = new ArrayList<>();
for (JsonNode node : array(json)) {
// 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다.
out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString()));
}
return out;
}
List<ContactLinkView> contacts(String json) {
List<ContactLinkView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ContactLinkView(
node.path("type").asString(""),
node.path("label").asString(""),
node.path("url").asString("")));
}
return out;
}
List<ProfileView.NamedDescription> namedDescriptions(String json) {
List<ProfileView.NamedDescription> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.NamedDescription(
node.path("name").asString(node.path("title").asString("")),
node.path("description").asString("")));
}
return out;
}
List<ProfileView.Territory> territories(String json) {
List<ProfileView.Territory> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.Territory(
node.path("name").asString(""),
node.path("currentQuestion").asString(null),
node.path("topicPath").asString(null)));
}
return out;
}
private Iterable<JsonNode> array(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : List.of();
} catch (JacksonException e) {
throw new MappingException("failed to read a public jsonb column", e);
}
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import java.util.List;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 공개 상세가 보여주는 관계.
*
* <p><b>어디서 읽는지가 중요하다.</b> 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code
* question_document_link})이 있지만 <b>그 테이블들에 쓰는 경로가 없다</b> — Studio 편집기가 만드는 관계는 전부 {@code
* studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면
* 관계가 항상 비어 보인다.
*
* <p>관계의 종류는 저장돼 있지 않으므로 <b>대상의 유형</b>으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences /
* projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다.
*/
final class PublicRelationLookup {
private final JdbcClient jdbcClient;
PublicRelationLookup(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */
List<RelatedEntryView> targetsOfType(String sourceKind, UUID sourceId, String targetType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.target_id"
+ " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId"
+ " AND p.resource_type = :targetType AND "
+ PublicSql.ACTIVE
+ " ORDER BY r.display_order")
.param("sourceKind", sourceKind)
.param("sourceId", sourceId)
.param("targetType", targetType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */
RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) {
return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null);
}
/** 이 기록을 가리키는 <b>역방향</b> 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */
List<RelatedEntryView> sourcesOfType(UUID targetId, String sourceType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.source_id"
+ " WHERE r.target_id = :targetId AND p.resource_type = :sourceType"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("targetId", targetId)
.param("sourceType", sourceType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */
RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM "
+ linkTable
+ " l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id"
+ " WHERE l."
+ idColumn
+ " = :id AND l.relation_type = 'PRIMARY'"
+ " AND "
+ PublicSql.ACTIVE)
.param("id", id)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
/**
* 공개 조회가 공유하는 SQL 조각.
*
* <p>"무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 를 빠뜨려도 드러나지 않고, 그
* 결과는 게시 취소한 문서가 계속 보이는 사고다.
*/
final class PublicSql {
/** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */
static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
/**
* 계약 {@code LatestEntry.entryType} 이 허용하는 값 중 이 projection 에 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로
* 조건도 한 곳에서 정의한다.
*/
static final String LATEST_ENTRY_TYPES =
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
private PublicSql() {}
/** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */
static String pathOf(String resourceType, String slug, String projectSlug) {
return switch (resourceType) {
case "CASE" -> "/cases/" + slug;
case "REFERENCE" -> "/references/" + slug;
case "QUESTION" -> "/questions/" + slug;
case "PROJECT" -> "/projects/" + slug;
case "PROJECT_DECISION" ->
projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug;
case "RELEASE" -> "/releases/" + slug;
default -> null;
};
}
}
@@ -0,0 +1,170 @@
-- public-v1 계약이 요구하는 나머지 테이블.
--
-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준)
--
-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가
-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다.
--
-- release -> listPublicReleases / getPublicRelease
-- site_config -> getPublicSite
-- profile_page -> getPublicProfile
-- home_focus_config -> getPublicHome (focus)
-- project_topic -> getPublicTopic (relatedProjects)
-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases)
--
-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에
-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다).
--
-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page /
-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이
-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을
-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이
-- 시딩이 유일한 공급원이다.
CREATE TABLE release (
id uuid PRIMARY KEY,
version_label varchar(32) NOT NULL,
title varchar(180) NOT NULL,
summary varchar(600) NOT NULL DEFAULT '',
released_on date,
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
change_types jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(change_types) = 'array'),
reason_markdown text NOT NULL DEFAULT '',
changes_markdown text NOT NULL DEFAULT '',
user_impact_markdown text NOT NULL DEFAULT '',
implementation_impact_markdown text NOT NULL DEFAULT '',
verification_markdown text NOT NULL DEFAULT '',
known_limitations_markdown text NOT NULL DEFAULT '',
related_resources jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(related_resources) = 'array'),
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_release_version_label UNIQUE (version_label)
);
CREATE TABLE site_config (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid),
brand_title varchar(80) NOT NULL DEFAULT 'Tech Log',
identity_statement varchar(600) NOT NULL DEFAULT '',
operator_display_name varchar(80) NOT NULL DEFAULT '',
short_identity varchar(120),
avatar_asset_id uuid REFERENCES asset(id),
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(contacts) = 'array'),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE profile_page (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid),
headline varchar(300) NOT NULL DEFAULT '',
introduction_markdown text NOT NULL DEFAULT '',
working_model jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(working_model) = 'array'),
territories jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(territories) = 'array'),
selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(selected_evidence) = 'array'),
trajectory jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(trajectory) = 'array'),
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(contacts) = 'array'),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE home_focus_config (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid),
default_focus_type varchar(30)
CHECK (default_focus_type IS NULL OR default_focus_type IN (
'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION'
)),
current_project_id uuid REFERENCES project(id),
open_question_id uuid REFERENCES open_question(id),
recent_decision_id uuid REFERENCES project_decision(id),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE project_topic (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
topic_id uuid NOT NULL REFERENCES topic(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (project_id, topic_id),
CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order)
);
CREATE TABLE topic_featured_document (
topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
feature_role varchar(30) NOT NULL
CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (topic_id, document_id, feature_role)
);
-- 한 Topic 의 START_HERE 는 하나뿐이다.
CREATE UNIQUE INDEX uq_topic_start_here
ON topic_featured_document(topic_id)
WHERE feature_role = 'START_HERE';
-- 단일 행 시딩. 이미 있으면 건드리지 않는다.
INSERT INTO site_config (
id,
brand_title,
identity_statement,
operator_display_name,
short_identity,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000001'::uuid,
'Tech Log',
'문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.',
'동현',
'Backend · Platform',
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
INSERT INTO profile_page (
id,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000002'::uuid,
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
INSERT INTO home_focus_config (
id,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000003'::uuid,
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
@@ -0,0 +1,965 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <p>{@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는
* Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일도 단위 테스트도
* 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다.
*
* <p>특히 두 가지를 겨냥한다.
*
* <ol>
* <li><b>공개 조건</b>({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나
* 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다.
* <li><b>총계와 목록이 같은 조건을 쓰는가</b> — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막
* 페이지가 비어 보이거나 없는 페이지 번호가 생긴다.
* </ol>
*/
class PublicSitePersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static final UUID SITE_CONFIG_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID PROFILE_PAGE_ID =
UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcPublicSiteQueryAdapter site;
private static JdbcPublicExploreQueryAdapter explore;
private static JdbcPublicTopicQueryAdapter topics;
private static JdbcPublicDocumentQueryAdapter documents;
private static JdbcPublicProjectQueryAdapter projects;
private static JdbcPublicReleaseQueryAdapter releases;
private static JdbcPublicSearchQueryAdapter search;
private static UUID topicId;
private static UUID projectId;
private static UUID tagId;
private static UUID caseId;
private static UUID referenceId;
private static UUID questionId;
private static UUID decisionId;
private static UUID hiddenCaseId;
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
@BeforeAll
static void migrateAndSeed() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the public-site persistence integration test;"
+ " skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
ObjectMapper objectMapper = new ObjectMapper();
site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper);
explore = new JdbcPublicExploreQueryAdapter(jdbcClient);
topics = new JdbcPublicTopicQueryAdapter(jdbcClient);
documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper);
projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper);
releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper);
search = new JdbcPublicSearchQueryAdapter(jdbcClient);
seed();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
// ---------------------------------------------------------------- V9 스키마
@Test
void v9CreatesEveryTableThePublicContractReads() {
assertThat(tableExists("release")).isTrue();
assertThat(tableExists("site_config")).isTrue();
assertThat(tableExists("profile_page")).isTrue();
assertThat(tableExists("home_focus_config")).isTrue();
assertThat(tableExists("project_topic")).isTrue();
assertThat(tableExists("topic_featured_document")).isTrue();
}
/** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */
@Test
void aTopicCanOnlyHaveOneStartHereDocument() {
UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe");
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
.param("t", scratchTopic)
.param("d", referenceId)
.update();
org.assertj.core.api.Assertions.assertThatThrownBy(
() ->
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 1)")
.param("t", scratchTopic)
.param("d", caseId)
.update())
.as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다")
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
jdbcClient
.sql("DELETE FROM topic_featured_document WHERE topic_id = :t")
.param("t", scratchTopic)
.update();
jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update();
}
// ---------------------------------------------------------------- 사이트 · 홈 · 프로필
@Test
void siteReadsTheSingleRowConfigWithItsContacts() {
SiteView view = site.site().orElseThrow();
assertThat(view.brandTitle()).isEqualTo("Tech Log");
assertThat(view.operatorDisplayName()).isEqualTo("동현");
assertThat(view.operatorProfilePath()).isEqualTo("/profile");
assertThat(view.contacts()).hasSize(1);
assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB");
assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example");
}
@Test
void homeResolvesTheConfiguredFocusAndTheLatestEntries() {
HomeView view = site.home(10);
assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK");
assertThat(view.focus().currentWork()).isNotNull();
assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log");
assertThat(view.latestEntries()).isNotEmpty();
assertThat(view.latestEntries())
.as("게시 취소된 자료는 최신 목록에 없어야 한다")
.noneMatch(entry -> entry.title().contains("숨김"));
assertThat(view.latestEntries())
.as(
"계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면"
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 된다")
.extracting("entryType")
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
}
/**
* 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL
* 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다.
*/
@Test
void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = NULL,"
+ " current_project_id = NULL, open_question_id = NULL,"
+ " recent_decision_id = NULL WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.update();
try {
HomeView view = site.home(10);
assertThat(view.focus().defaultType())
.isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
assertThat(view.focus().currentWork()).isNull();
assertThat(view.focus().openQuestion()).isNull();
assertThat(view.focus().recentDecision()).isNull();
// 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다.
jdbcClient
.sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id")
.param("q", questionId)
.param("id", HOME_FOCUS_ID)
.update();
assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION");
} finally {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
}
}
@Test
void profileReadsItsJsonbColumnsIntoTypedViews() {
ProfileView view = site.profile().orElseThrow();
assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다");
assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현");
assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka");
assertThat(view.contacts()).hasSize(1);
assertThat(view.selectedEvidence())
.as("selected_evidence 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
}
// ---------------------------------------------------------------- 탐색
@Test
void knowledgeListsOnlyPublishedCasesAndReferences() {
KnowledgePageView page =
explore.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20)));
assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준");
assertThat(page.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(page.page().totalElements())
.as("총계와 목록이 같은 조건을 써야 한다")
.isEqualTo(page.items().size());
}
@Test
void knowledgeAppliesEveryContractFilter() {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20)))
.items())
.extracting("type")
.containsOnly("CASE");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20)))
.items())
.isNotEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, "no-such-topic", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, null, null, "reprocessing", null, null, page(1, 20)))
.items())
.as("tag 필터의 상관 EXISTS 서브쿼리")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20)))
.items())
.as("year 필터는 date_part 로 건다")
.isEmpty();
}
/** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */
@Test
void knowledgeAcceptsEveryContractSort() {
for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
@Test
void questionsListAppliesStatusTagAndEverySort() {
QuestionPageView all =
explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20)));
assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까");
assertThat(
explore
.questions(
new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.questions(
new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20)))
.items())
.as("질문에도 tag 필터가 걸려야 한다")
.isNotEmpty();
for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) {
assertThat(
explore
.questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
// ---------------------------------------------------------------- 주제
@Test
void topicListCountsOnlyPublishedRecords() {
assertThat(topics.list()).extracting("slug").contains("kafka");
var kafka =
topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow();
// Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다.
assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3);
}
@Test
void topicDetailResolvesEverySection() {
TopicDetailView view = topics.findBySlug("kafka").orElseThrow();
assertThat(view.name()).isEqualTo("Kafka");
assertThat(view.featuredReference()).isNotNull();
assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준");
assertThat(view.activeQuestions()).isNotEmpty();
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
assertThat(view.latestRecords()).isNotEmpty();
assertThat(view.latestRecords())
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
.extracting("entryType")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
}
@Test
void topicDetailIsAbsentForAnUnknownSlug() {
assertThat(topics.findBySlug("no-such-topic")).isEmpty();
}
// ---------------------------------------------------------------- 문서 상세
@Test
void caseDetailReadsTheOriginalTableNotTheProjectionPayload() {
CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow();
assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing");
assertThat(view.document().title()).isEqualTo("Kafka 재처리");
assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다");
assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다");
assertThat(view.document().content()).contains("# 재처리");
assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN");
assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7");
assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka");
assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log");
assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing");
}
@Test
void referenceDetailReadsItsOwnScopeColumns() {
ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow();
assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다");
assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x");
assertThat(view.document().excludedScope()).containsExactly("Kinesis");
assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT");
}
@Test
void questionDetailReadsItsTimelineAndResolutionColumns() {
QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow();
assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까");
assertThat(view.question().status()).isEqualTo("OPEN");
assertThat(view.question().resolvedAt()).isNull();
assertThat(view.question().points()).isNotNull();
}
@Test
void aWithdrawnDocumentIsNotReadable() {
assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 프로젝트
@Test
void projectListAndDetailReadEveryPublishedColumn() {
assertThat(projects.list()).extracting("slug").containsExactly("tech-log");
ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow();
assertThat(view.project().name()).isEqualTo("Tech Log");
assertThat(view.project().technologies()).contains("Spring Boot");
assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log");
assertThat(view.featuredDecision()).isNotNull();
assertThat(view.selectedRecords()).isNotEmpty();
}
@Test
void projectSubListsReturnEmptyOptionalForAnUnknownProject() {
assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20))))
.isEmpty();
assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20))))
.isEmpty();
assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty();
}
@Test
void projectDecisionsApplyTheStatusFilterToBothCountAndPage() {
var all =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).hasSize(1);
assertThat(all.page().totalElements()).isEqualTo(1);
var accepted =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20)))
.orElseThrow();
assertThat(accepted.items()).hasSize(1);
assertThat(accepted.page().totalElements()).isEqualTo(1);
var proposed =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20)))
.orElseThrow();
assertThat(proposed.items()).isEmpty();
assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero();
}
@Test
void projectRecordsApplyTypeAndRelationFilters() {
var all =
projects
.records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).isNotEmpty();
assertThat(all.page().totalElements()).isEqualTo(all.items().size());
var cases =
projects
.records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20)))
.orElseThrow();
assertThat(cases.items()).extracting("type").containsOnly("CASE");
var related =
projects
.records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20)))
.orElseThrow();
assertThat(related.page().totalElements()).isEqualTo(related.items().size());
var none =
projects
.records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20)))
.orElseThrow();
assertThat(none.page().totalElements()).isEqualTo(none.items().size());
}
@Test
void projectActivitiesListOnlyPublicOnes() {
var activities =
projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow();
assertThat(activities.items()).extracting("title").containsExactly("첫 게시");
assertThat(activities.page().totalElements()).isEqualTo(1);
}
// ---------------------------------------------------------------- 릴리스
@Test
void releasesListOnlyPublishedOnesAndResolveRelatedRecords() {
assertThat(releases.list()).extracting("version").containsExactly("1.0.0");
ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow();
assertThat(detail.title()).isEqualTo("첫 공개");
assertThat(detail.changeTypes()).containsExactly("ADDED");
assertThat(detail.relatedRecords())
.as("related_resources 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 검색
@Test
void searchMatchesOnSearchTextAndAppliesFilters() {
SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20)));
assertThat(hits.query()).isEqualTo("재처리");
assertThat(hits.items()).isNotEmpty();
assertThat(hits.page().totalElements()).isEqualTo(hits.items().size());
assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items())
.extracting("contentType")
.containsOnly("REFERENCE");
assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items())
.as("type 필터가 실제로 걸려야 한다")
.isEmpty();
assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items())
.isEmpty();
}
// ---------------------------------------------------------------- 시딩
/**
* 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code
* public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다.
*/
private static void seed() {
topicId = insertTopic("kafka", "Kafka");
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown,"
+ " boundary_markdown, system_overview_markdown, phase, current_objective,"
+ " next_step, technology_labels, workflow_status, target_visibility,"
+ " created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요',"
+ " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트',"
+ " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC',"
+ " 'test', 'test')")
.param("id", projectId)
.update();
tagId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')")
.param("id", tagId)
.update();
// --- Case (공개) ---
caseId = UUID.randomUUID();
insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId);
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, problem_summary, conclusion_summary,"
+ " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다',"
+ " '[\"Kafka 3.7\"]'::jsonb)")
.param("id", caseId)
.update();
publish(
"CASE",
caseId,
"Kafka 재처리",
"재처리가 지연된다",
"/cases/kafka-reprocessing",
"ACTIVE",
"PUBLIC",
topicId);
link(caseId, "CASE", "PRIMARY", 0);
tag(caseId, "CASE");
// 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다.
jdbcClient
.sql(
"INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)")
.param("d", caseId)
.param("t", tagId)
.update();
// --- Reference (공개) ---
referenceId = UUID.randomUUID();
insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId);
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, scope_summary, applies_to,"
+ " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다',"
+ " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')")
.param("id", referenceId)
.update();
publish(
"REFERENCE",
referenceId,
"Kafka 운영 기준",
"운영 기준을 정한다",
"/references/kafka-operations",
"ACTIVE",
"PUBLIC",
topicId);
link(referenceId, "REFERENCE", "RELATED", null);
// --- Case (게시 취소) — 어느 경로로도 새면 안 된다 ---
hiddenCaseId = UUID.randomUUID();
insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId);
jdbcClient
.sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')")
.param("id", hiddenCaseId)
.update();
publish(
"CASE",
hiddenCaseId,
"숨김 Case",
"재처리 비밀",
"/cases/hidden-case",
"WITHDRAWN",
"PUBLIC",
topicId);
// --- OpenQuestion (공개) ---
questionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, context_markdown,"
+ " importance_markdown, next_verification, question_status, target_visibility,"
+ " primary_topic_id, opened_at, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까',"
+ " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic,"
+ " :openedAt, 'test', 'test')")
.param("id", questionId)
.param("topic", topicId)
.param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS)))
.update();
publish(
"QUESTION",
questionId,
"재처리 지연을 어떻게 줄일까",
"지연 원인을 좁힌다",
"/questions/reprocessing-latency",
"ACTIVE",
"PUBLIC",
topicId);
// 질문 목록의 status 필터는 projection 의 state_code 를 본다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET state_code = 'OPEN'"
+ " WHERE resource_type = 'QUESTION' AND resource_id = :id")
.param("id", questionId)
.update();
link(questionId, "QUESTION", "PRIMARY", 1);
tag(questionId, "QUESTION");
// --- ProjectDecision (공개) ---
decisionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, statement, rationale_markdown,"
+ " consequences, decision_status, target_visibility, source_question_id,"
+ " source_case_id, is_featured, decided_at, created_by, updated_by)"
+ " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다',"
+ " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase,"
+ " true, :decidedAt, 'test', 'test')")
.param("id", decisionId)
.param("project", projectId)
.param("question", questionId)
.param("sourceCase", caseId)
.param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS)))
.update();
publish(
"PROJECT_DECISION",
decisionId,
"재처리는 별도 토픽으로 분리한다",
"격리해야 관측이 쉬워진다",
"/projects/tech-log/decisions/" + decisionId,
"ACTIVE",
"PUBLIC",
null);
// --- Project (공개) ---
publish(
"PROJECT",
projectId,
"Tech Log",
"기록을 남긴다",
"/projects/tech-log",
"ACTIVE",
"PUBLIC",
null);
// --- 활동: 공개 하나 · 비공개 하나 ---
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시',"
+ " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at,"
+ " 'test', 'test')")
.param("project", projectId)
.param("relatedCase", caseId)
.param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS)))
.update();
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, visibility,"
+ " origin, occurred_at, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE',"
+ " 'MANUAL', :at, 'test', 'test')")
.param("project", projectId)
.param("at", java.sql.Timestamp.from(NOW))
.update();
// --- V9 연결 테이블 ---
jdbcClient
.sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)")
.param("p", projectId)
.param("t", topicId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
.param("t", topicId)
.param("d", referenceId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)")
.param("t", topicId)
.param("d", caseId)
.update();
// --- 단일 행 설정 ---
jdbcClient
.sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id")
.param("id", SITE_CONFIG_ID)
.param(
"contacts",
"[{\"type\":\"GITHUB\",\"label\":\"GitHub\","
+ "\"url\":\"https://github.com/example\"}]")
.update();
// V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다.
jdbcClient
.sql(
"UPDATE profile_page SET headline = '문제를 재현해 검증한다',"
+ " introduction_markdown = '소개', working_model = :workingModel::jsonb,"
+ " territories = :territories::jsonb, selected_evidence = :evidence::jsonb,"
+ " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb,"
+ " target_visibility = 'PUBLIC' WHERE id = :id")
.param("id", PROFILE_PAGE_ID)
.param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]")
.param(
"territories",
"[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\","
+ "\"topicPath\":\"/topics/kafka\"}]")
// 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다.
.param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]")
.param(
"contacts",
"[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]")
.update();
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
// --- 릴리스: 공개 하나 · 초안 하나 ---
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, change_types, changes_markdown, verification_markdown,"
+ " related_resources, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다',"
+ " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증',"
+ " :related::jsonb, 'test', 'test')")
// 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다.
.param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.update();
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01',"
+ " 'DRAFT', 'test', 'test')")
.update();
}
private static UUID insertTopic(String slug, String name) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, description, scope,"
+ " status, created_by, updated_by)"
+ " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위',"
+ " 'ACTIVE', 'test', 'test')")
.param("id", id)
.param("name", name)
.param("slug", slug)
.update();
return id;
}
private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) {
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, body_markdown,"
+ " content_format, content_format_version, workflow_status, target_visibility,"
+ " primary_topic_id, last_verified_at, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1,"
+ " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')")
.param("id", id)
.param("type", type)
.param("slug", slug)
.param("title", title)
.param("topic", topic)
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
.update();
}
private static void link(UUID resourceId, String type, String relation, Integer order) {
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type, featured_order)"
+ " VALUES (:type, :id, :project, :relation, :order)")
.param("type", type)
.param("id", resourceId)
.param("project", projectId)
.param("relation", relation)
.param("order", order)
.update();
}
private static void tag(UUID resourceId, String type) {
jdbcClient
.sql(
"INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)"
+ " VALUES (:type, :id, :tag, 0)")
.param("type", type)
.param("id", resourceId)
.param("tag", tagId)
.update();
}
/**
* projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을
* (resource_type, resource_id) 복합 FK 로 참조하므로 <b>반드시 링크·태그보다 먼저</b> 삽입해야 한다.
*
* <p>{@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든
* projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다.
*/
private static void publish(
String type,
UUID id,
String title,
String summary,
String path,
String state,
String visibility,
UUID topic) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, last_verified_at, navigation_path)"
+ " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1,"
+ " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt,"
+ " :verifiedAt, :path)")
.param("type", type)
.param("id", id)
.param("state", state)
.param("visibility", visibility)
.param("title", title)
.param("summary", summary)
.param("topic", topic)
.param("body", title + " " + summary)
.param("search", title + " " + summary)
.param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS)))
.param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS)))
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
.param("path", path)
.update();
}
private static PublicPageRequest page(int page, int size) {
return new PublicPageRequest(page, size);
}
private static boolean tableExists(String table) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables"
+ " WHERE table_schema = 'public' AND table_name = :t)")
.param("t", table)
.query(Boolean.class)
.single());
}
}