feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)
studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.
Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다
설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
(source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다
영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.
nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.
렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다
검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.
게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다
Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다
검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다
가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록
범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.
검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e615c24152
commit
48ff648112
@@ -26,6 +26,12 @@ dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
|
||||
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
|
||||
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
|
||||
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
|
||||
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jackson'
|
||||
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
|
||||
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
|
||||
implementation 'org.springframework.integration:spring-integration-jdbc'
|
||||
@@ -117,6 +123,14 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe
|
||||
'postgresqlTechLogCatalogQueryIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
|
||||
|
||||
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
|
||||
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
|
||||
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
|
||||
// 않은 채로 빌드가 통과한다.
|
||||
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlTechLogStudioPersistenceIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
|
||||
|
||||
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
||||
group = 'verification'
|
||||
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
||||
|
||||
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
|
||||
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* {@code studio_preview} 접근.
|
||||
*
|
||||
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
|
||||
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
selectColumns()
|
||||
+ " WHERE source_kind = :kind AND source_id = :id"
|
||||
+ " ORDER BY created_at DESC LIMIT 1")
|
||||
.param("kind", kind.name())
|
||||
.param("id", documentId)
|
||||
.query(JdbcPreviewArtifactAdapter::mapRow)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicPreviewView> findById(UUID previewId) {
|
||||
return jdbcClient
|
||||
.sql(selectColumns() + " WHERE preview_id = :id")
|
||||
.param("id", previewId)
|
||||
.query(JdbcPreviewArtifactAdapter::mapRow)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
|
||||
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
|
||||
+ " created_by)"
|
||||
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
|
||||
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
|
||||
+ " :principal)")
|
||||
.param("previewId", preview.previewId())
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", preview.documentId())
|
||||
.param("sourceVersion", preview.previewVersion())
|
||||
.param("validationId", preview.validationId())
|
||||
.param("dependencyRevision", preview.dependencyRevision())
|
||||
.param("renderModel", preview.renderModelJson())
|
||||
.param("createdAt", Timestamp.from(preview.createdAt()))
|
||||
.param("expiresAt", Timestamp.from(preview.expiresAt()))
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static String selectColumns() {
|
||||
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
|
||||
+ " render_model, created_at, expires_at FROM studio_preview";
|
||||
}
|
||||
|
||||
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PublicPreviewView(
|
||||
rs.getObject("preview_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
rs.getLong("source_version"),
|
||||
rs.getObject("validation_id", UUID.class),
|
||||
rs.getString("dependency_revision"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getTimestamp("expires_at").toInstant(),
|
||||
rs.getString("render_model"));
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
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.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
|
||||
*
|
||||
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
|
||||
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
selectColumns()
|
||||
+ " WHERE source_kind = :kind AND source_id = :id"
|
||||
+ " ORDER BY validated_at DESC LIMIT 1")
|
||||
.param("kind", kind.name())
|
||||
.param("id", documentId)
|
||||
.query(this::mapRow)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ValidationReportView> findById(UUID validationId) {
|
||||
return jdbcClient
|
||||
.sql(selectColumns() + " WHERE validation_id = :id")
|
||||
.param("id", validationId)
|
||||
.query(this::mapRow)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
|
||||
+ " validated_version, status, issues, dependency_revision, validated_at,"
|
||||
+ " valid_until, created_by)"
|
||||
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
|
||||
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
|
||||
+ " :principal)")
|
||||
.param("validationId", report.validationId())
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", report.documentId())
|
||||
.param("validatedVersion", report.validatedVersion())
|
||||
.param("status", report.status().name())
|
||||
.param("issues", issuesToJson(report.issues()))
|
||||
.param("dependencyRevision", report.dependencyRevision())
|
||||
.param("validatedAt", Timestamp.from(report.validatedAt()))
|
||||
.param("validUntil", Timestamp.from(report.validUntil()))
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
return report;
|
||||
}
|
||||
|
||||
private static String selectColumns() {
|
||||
return "SELECT validation_id, source_id, validated_version, status, issues,"
|
||||
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
|
||||
}
|
||||
|
||||
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new ValidationReportView(
|
||||
rs.getObject("validation_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
rs.getLong("validated_version"),
|
||||
ValidationStatus.valueOf(rs.getString("status")),
|
||||
issuesFromJson(rs.getString("issues")),
|
||||
rs.getTimestamp("validated_at").toInstant(),
|
||||
rs.getTimestamp("valid_until").toInstant(),
|
||||
rs.getString("dependency_revision"));
|
||||
}
|
||||
|
||||
private String issuesToJson(List<ValidationIssueView> issues) {
|
||||
ArrayNode array = objectMapper.createArrayNode();
|
||||
for (ValidationIssueView issue : issues) {
|
||||
ObjectNode node = array.addObject();
|
||||
node.put("code", issue.code());
|
||||
node.put("severity", issue.severity().name());
|
||||
node.put("path", issue.path());
|
||||
node.put("message", issue.message());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(array);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to serialise validation issues", e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ValidationIssueView> issuesFromJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode array = objectMapper.readTree(json);
|
||||
if (!array.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
return array
|
||||
.valueStream()
|
||||
.map(
|
||||
node ->
|
||||
new ValidationIssueView(
|
||||
node.path("code").asString(""),
|
||||
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
|
||||
node.path("path").asString(""),
|
||||
node.path("message").asString("")))
|
||||
.toList();
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to read validation issues", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.StudioError;
|
||||
import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다.
|
||||
*
|
||||
* <p>목록이 쓰는 SQL과 <b>같은 식</b>을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는
|
||||
* "다시 검증하라"로 보인다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String revisionFor(RecordKind kind, UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
StudioDocumentSql.documentProjectionCte()
|
||||
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
|
||||
.param("id", documentId)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
StudioException.of(
|
||||
StudioError.DOCUMENT_NOT_FOUND,
|
||||
"cannot compute a dependency revision for unknown document " + documentId));
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
|
||||
@Repository
|
||||
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||
+ " latest_event_id, public_path, updated_at"
|
||||
+ " FROM publication WHERE source_id = :id")
|
||||
.param("id", documentId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new PublicationAggregateView(
|
||||
rs.getObject("publication_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||
rs.getLong("published_version"),
|
||||
rs.getLong("publication_revision"),
|
||||
rs.getObject("latest_event_id", UUID.class),
|
||||
rs.getString("public_path"),
|
||||
rs.getTimestamp("updated_at").toInstant()))
|
||||
.optional();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
|
||||
import java.util.List;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는
|
||||
* 문서가 달라지면 숫자를 믿을 수 없다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
StudioDocumentSql.documentProjectionCte()
|
||||
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
|
||||
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
|
||||
.param("actions", actions.stream().map(Enum::name).toList())
|
||||
.param("limit", limit)
|
||||
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DashboardTotalsView totals() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
StudioDocumentSql.documentProjectionCte()
|
||||
+ " SELECT count(*) AS documents,"
|
||||
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
|
||||
+ " AS needs_validation,"
|
||||
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
|
||||
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
|
||||
+ " FROM studio_document")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new DashboardTotalsView(
|
||||
rs.getInt("documents"),
|
||||
rs.getInt("needs_validation"),
|
||||
rs.getInt("ready_to_publish"),
|
||||
rs.getInt("publications")))
|
||||
.single();
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
|
||||
@Repository
|
||||
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
|
||||
UUID topicId = document.base().topicId();
|
||||
UUID projectId = document.base().projectId();
|
||||
|
||||
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
|
||||
DisplayTargetView project = projectId == null ? null : findProject(projectId);
|
||||
|
||||
List<ResolvedRelationView> relations = new ArrayList<>();
|
||||
List<UUID> missingTargets = new ArrayList<>();
|
||||
resolveRelations(document, relations, missingTargets);
|
||||
|
||||
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
|
||||
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
|
||||
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
|
||||
|
||||
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
|
||||
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
|
||||
|
||||
return new Resolved(
|
||||
topic,
|
||||
topicId != null && topic == null,
|
||||
project,
|
||||
projectId != null && project == null,
|
||||
relations,
|
||||
missingTargets,
|
||||
assetsByKey,
|
||||
assetStatusByKey,
|
||||
resolveEvidenceTarget(document),
|
||||
publicPath,
|
||||
findSlugOwner(document));
|
||||
}
|
||||
|
||||
private DisplayTargetView findTopic(UUID topicId) {
|
||||
return jdbcClient
|
||||
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
|
||||
.param("id", topicId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new DisplayTargetView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
"/topics/" + rs.getString("slug")))
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private DisplayTargetView findProject(UUID projectId) {
|
||||
return jdbcClient
|
||||
.sql("SELECT id, name, slug FROM project WHERE id = :id")
|
||||
.param("id", projectId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new DisplayTargetView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String findProjectSlug(UUID projectId) {
|
||||
return jdbcClient
|
||||
.sql("SELECT slug FROM project WHERE id = :id")
|
||||
.param("id", projectId)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code
|
||||
* RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다.
|
||||
*/
|
||||
private void resolveRelations(
|
||||
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
|
||||
|
||||
for (RelationView relation : document.base().relations()) {
|
||||
if (relation.targetId() == null) {
|
||||
continue;
|
||||
}
|
||||
TargetRow row = findTarget(relation.targetId());
|
||||
if (row == null) {
|
||||
missing.add(relation.targetId());
|
||||
continue;
|
||||
}
|
||||
resolved.add(
|
||||
new ResolvedRelationView(
|
||||
relation.id(),
|
||||
relation.targetId(),
|
||||
row.kind(),
|
||||
row.title(),
|
||||
row.publicPath(),
|
||||
relation.reason() == null ? "" : relation.reason(),
|
||||
relation.order()));
|
||||
}
|
||||
}
|
||||
|
||||
private record TargetRow(String kind, String title, String publicPath) {}
|
||||
|
||||
private TargetRow findTarget(UUID targetId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
|
||||
+ " WHERE id = :id"
|
||||
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
|
||||
+ " WHERE id = :id"
|
||||
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
|
||||
+ " WHERE id = :id"
|
||||
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
|
||||
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
|
||||
+ " WHERE pd.id = :id")
|
||||
.param("id", targetId)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
String kind = rs.getString("kind");
|
||||
String slug = rs.getString("slug");
|
||||
String publicPath =
|
||||
"PROJECT".equals(kind)
|
||||
? (slug == null ? null : "/projects/" + slug)
|
||||
: PublicPaths.forKind(
|
||||
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
|
||||
return new TargetRow(kind, rs.getString("title"), publicPath);
|
||||
})
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private void resolveAssets(
|
||||
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
|
||||
if (keys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
|
||||
+ " management_status FROM asset WHERE asset_key IN (:keys)")
|
||||
.param("keys", keys)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
String key = rs.getString("asset_key");
|
||||
statuses.put(key, rs.getString("management_status"));
|
||||
assets.put(
|
||||
key,
|
||||
new ResolvedAssetView(
|
||||
rs.getObject("id", UUID.class),
|
||||
key,
|
||||
rs.getString("content_type"),
|
||||
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
|
||||
// (설계 05장 §3.1).
|
||||
"/media/" + rs.getString("id"),
|
||||
(Integer) rs.getObject("width"),
|
||||
(Integer) rs.getObject("height"),
|
||||
rs.getBoolean("decorative")));
|
||||
return key;
|
||||
})
|
||||
.list();
|
||||
}
|
||||
|
||||
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
|
||||
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|
||||
|| value.resolution() == null
|
||||
|| value.resolution().evidenceTargetId() == null) {
|
||||
return null;
|
||||
}
|
||||
TargetRow row = findTarget(value.resolution().evidenceTargetId());
|
||||
return row == null
|
||||
? null
|
||||
: new DisplayTargetView(
|
||||
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와
|
||||
* {@code /questions/x} 는 다른 주소다).
|
||||
*/
|
||||
private UUID findSlugOwner(WorkingCopyView document) {
|
||||
String slug = document.base().slug();
|
||||
if (slug == null || slug.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String sql =
|
||||
switch (document.kind()) {
|
||||
case CASE, REFERENCE ->
|
||||
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
|
||||
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
|
||||
case PROJECT_DECISION ->
|
||||
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
|
||||
};
|
||||
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
|
||||
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
|
||||
spec = spec.param("kind", document.kind().name());
|
||||
}
|
||||
return spec.query(UUID.class).optional().orElse(null);
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
|
||||
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* {@code listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
|
||||
* 설명, spec §8.3).
|
||||
*
|
||||
* <p>정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에
|
||||
* 실제로 일어나는 일이다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DocumentPageView list(ListDocumentsQuery query) {
|
||||
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
|
||||
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
|
||||
Map<String, Object> params = new java.util.HashMap<>();
|
||||
|
||||
if (query.kind() != null) {
|
||||
sql.append(" AND kind = :kind");
|
||||
params.put("kind", query.kind().name());
|
||||
}
|
||||
if (query.projectId() != null) {
|
||||
sql.append(" AND project_id = :projectId");
|
||||
params.put("projectId", query.projectId());
|
||||
}
|
||||
if (query.nextAction() != null) {
|
||||
sql.append(" AND next_action = :nextAction");
|
||||
params.put("nextAction", query.nextAction().name());
|
||||
}
|
||||
if (query.publicationStatus() != null) {
|
||||
sql.append(publicationStatusPredicate());
|
||||
params.put("publicationStatus", query.publicationStatus().name());
|
||||
}
|
||||
if (query.query() != null && !query.query().isBlank()) {
|
||||
sql.append(" AND lower(title) LIKE :titlePattern");
|
||||
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
|
||||
}
|
||||
appendCursorPredicate(sql, params, query);
|
||||
sql.append(orderBy(query.sort()));
|
||||
// limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다.
|
||||
sql.append(" LIMIT :limitPlusOne");
|
||||
params.put("limitPlusOne", query.limit() + 1);
|
||||
|
||||
var spec = jdbcClient.sql(sql.toString());
|
||||
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||
spec = spec.param(param.getKey(), param.getValue());
|
||||
}
|
||||
|
||||
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
|
||||
boolean hasMore = rows.size() > query.limit();
|
||||
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||
|
||||
List<DocumentSummaryView> items = new ArrayList<>(page.size());
|
||||
for (Row row : page) {
|
||||
items.add(row.summary());
|
||||
}
|
||||
return new DocumentPageView(
|
||||
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이
|
||||
* 아니다.
|
||||
*/
|
||||
private static String publicationStatusPredicate() {
|
||||
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
|
||||
+ " OR publication_status = :publicationStatus)";
|
||||
}
|
||||
|
||||
private static String orderBy(DocumentSort sort) {
|
||||
return switch (sort) {
|
||||
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
|
||||
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
|
||||
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
|
||||
};
|
||||
}
|
||||
|
||||
private static void appendCursorPredicate(
|
||||
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
|
||||
DocumentCursorPosition position = query.position();
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가
|
||||
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다).
|
||||
String predicate =
|
||||
switch (query.sort()) {
|
||||
case UPDATED_DESC -> {
|
||||
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
|
||||
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
|
||||
}
|
||||
case UPDATED_ASC -> {
|
||||
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
|
||||
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
|
||||
}
|
||||
case TITLE_ASC -> {
|
||||
params.put("cursorTitle", position.title());
|
||||
yield " AND (title, id) > (:cursorTitle, :cursorId)";
|
||||
}
|
||||
};
|
||||
params.put("cursorId", position.id());
|
||||
sql.append(predicate);
|
||||
}
|
||||
|
||||
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
|
||||
private static String cursorPayload(Row last, DocumentSort sort) {
|
||||
return switch (sort) {
|
||||
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
|
||||
case TITLE_ASC -> last.title() + "|" + last.summary().id();
|
||||
};
|
||||
}
|
||||
|
||||
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
|
||||
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
|
||||
|
||||
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
return new Row(
|
||||
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@code studio_document} 한 행 → 계약 {@code DocumentSummary}.
|
||||
*
|
||||
* <p>목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
|
||||
* hasUnpublishedChanges} 로 보인다.
|
||||
*/
|
||||
public final class StudioDocumentRowMapper {
|
||||
|
||||
private StudioDocumentRowMapper() {}
|
||||
|
||||
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
|
||||
UUID projectId = rs.getObject("project_id", UUID.class);
|
||||
String projectSlug = rs.getString("project_slug");
|
||||
Long publishedVersion = (Long) rs.getObject("published_version");
|
||||
long version = rs.getLong("version");
|
||||
String publicationStatus = rs.getString("publication_status");
|
||||
|
||||
return new DocumentSummaryView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("title"),
|
||||
RecordKind.valueOf(rs.getString("kind")),
|
||||
projectId == null
|
||||
? null
|
||||
: new DisplayTargetView(
|
||||
projectId,
|
||||
rs.getString("project_name"),
|
||||
projectSlug == null ? null : "/projects/" + projectSlug),
|
||||
rs.getTimestamp("updated_at").toInstant(),
|
||||
publicationStatus == null
|
||||
? PublicationStatusView.NEVER_PUBLISHED
|
||||
: PublicationStatusView.valueOf(publicationStatus),
|
||||
publishedVersion,
|
||||
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
|
||||
publishedVersion != null && publishedVersion != version,
|
||||
NextAction.valueOf(rs.getString("next_action")));
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||
|
||||
/**
|
||||
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
|
||||
*
|
||||
* <p>{@code dependencyRevision}과 {@code nextAction}은 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL 한
|
||||
* 곳과 Java 한 곳에 따로 있으면 목록의 {@code nextAction}과 상세의 {@code nextAction}이 조용히 갈라진다 — 사용자에게는 "목록에서는
|
||||
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
|
||||
*/
|
||||
public final class StudioDocumentSql {
|
||||
|
||||
/**
|
||||
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
|
||||
*/
|
||||
public static final String RENDERER_CONTRACT_VERSION = "1";
|
||||
|
||||
private StudioDocumentSql() {}
|
||||
|
||||
/**
|
||||
* 네 유형을 하나의 행 모양으로 모으고, 그 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
|
||||
*
|
||||
* <p>마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다.
|
||||
*
|
||||
* <pre>{@code
|
||||
* id kind title version updated_at topic_id project_id project_name project_slug
|
||||
* dependency_revision
|
||||
* validation_version validation_status validation_valid_until validation_revision
|
||||
* preview_version preview_expires_at preview_revision
|
||||
* publication_status published_version
|
||||
* next_action
|
||||
* }</pre>
|
||||
*/
|
||||
public static String documentProjectionCte() {
|
||||
return """
|
||||
WITH studio_source AS (
|
||||
SELECT d.id,
|
||||
d.document_type AS kind,
|
||||
d.title,
|
||||
d.version,
|
||||
d.updated_at,
|
||||
d.primary_topic_id AS topic_id,
|
||||
(SELECT l.project_id FROM project_document_link l
|
||||
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
|
||||
FROM document d
|
||||
UNION ALL
|
||||
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
|
||||
(SELECT l.project_id FROM project_question_link l
|
||||
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
|
||||
FROM open_question q
|
||||
UNION ALL
|
||||
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
|
||||
pd.primary_topic_id, pd.project_id
|
||||
FROM project_decision pd
|
||||
),
|
||||
studio_dependency AS (
|
||||
SELECT s.id,
|
||||
md5(concat_ws('|',
|
||||
coalesce(t.id::text || ':' || t.version::text, '-'),
|
||||
coalesce(p.id::text || ':' || p.version::text, '-'),
|
||||
coalesce((
|
||||
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
|
||||
FROM (
|
||||
SELECT r.target_id::text || ':'
|
||||
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
|
||||
FROM studio_relation r
|
||||
LEFT JOIN document rd ON rd.id = r.target_id
|
||||
LEFT JOIN open_question rq ON rq.id = r.target_id
|
||||
LEFT JOIN project_decision rp ON rp.id = r.target_id
|
||||
WHERE r.source_kind = s.kind AND r.source_id = s.id
|
||||
) rel
|
||||
), '-'),
|
||||
'renderer:%s'
|
||||
)) AS dependency_revision
|
||||
FROM studio_source s
|
||||
LEFT JOIN topic t ON t.id = s.topic_id
|
||||
LEFT JOIN project p ON p.id = s.project_id
|
||||
),
|
||||
studio_latest_validation AS (
|
||||
SELECT DISTINCT ON (v.source_id)
|
||||
v.source_id, v.validation_id, v.validated_version, v.status,
|
||||
v.valid_until, v.dependency_revision
|
||||
FROM studio_validation v
|
||||
ORDER BY v.source_id, v.validated_at DESC
|
||||
),
|
||||
studio_latest_preview AS (
|
||||
SELECT DISTINCT ON (pv.source_id)
|
||||
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
|
||||
pv.dependency_revision
|
||||
FROM studio_preview pv
|
||||
ORDER BY pv.source_id, pv.created_at DESC
|
||||
),
|
||||
studio_document AS (
|
||||
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
|
||||
pr.name AS project_name, pr.slug AS project_slug,
|
||||
dep.dependency_revision,
|
||||
val.validated_version AS validation_version,
|
||||
val.status AS validation_status,
|
||||
val.valid_until AS validation_valid_until,
|
||||
val.dependency_revision AS validation_revision,
|
||||
prev.source_version AS preview_version,
|
||||
prev.expires_at AS preview_expires_at,
|
||||
prev.dependency_revision AS preview_revision,
|
||||
pub.status AS publication_status,
|
||||
pub.published_version,
|
||||
CASE
|
||||
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
|
||||
WHEN val.validated_version IS NULL
|
||||
OR val.validated_version <> s.version
|
||||
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
|
||||
OR val.valid_until <= now() THEN 'VALIDATE'
|
||||
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
|
||||
WHEN prev.source_version IS NULL
|
||||
OR prev.source_version <> s.version
|
||||
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
|
||||
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
|
||||
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
|
||||
OR pub.published_version <> s.version THEN 'PUBLISH'
|
||||
ELSE 'NONE'
|
||||
END AS next_action
|
||||
FROM studio_source s
|
||||
JOIN studio_dependency dep ON dep.id = s.id
|
||||
LEFT JOIN project pr ON pr.id = s.project_id
|
||||
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
|
||||
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
|
||||
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
|
||||
)
|
||||
"""
|
||||
.formatted(RENDERER_CONTRACT_VERSION);
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* {@code asset} 메타데이터 접근.
|
||||
*
|
||||
* <p>{@code usageCount} 는 {@code asset_reference} 에서 센다 — Asset 행에 캐시해 두면 참조가 바뀔 때마다 두 곳을 맞춰야 하고,
|
||||
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssetPageView list(ListAssetsQuery query) {
|
||||
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
if (query.kind() != null) {
|
||||
sql.append(" AND a.asset_kind = :kind");
|
||||
params.put("kind", query.kind().name());
|
||||
}
|
||||
if (query.managementStatus() != null) {
|
||||
sql.append(" AND a.management_status = :status");
|
||||
params.put("status", query.managementStatus().name());
|
||||
}
|
||||
if (query.query() != null && !query.query().isBlank()) {
|
||||
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
|
||||
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
|
||||
}
|
||||
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
|
||||
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
|
||||
params.put("before", Timestamp.from(query.beforeCreatedAt()));
|
||||
params.put("beforeId", query.beforeId());
|
||||
}
|
||||
sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne");
|
||||
params.put("limitPlusOne", query.limit() + 1);
|
||||
|
||||
var spec = jdbcClient.sql(sql.toString());
|
||||
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||
spec = spec.param(param.getKey(), param.getValue());
|
||||
}
|
||||
List<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
|
||||
|
||||
boolean hasMore = rows.size() > query.limit();
|
||||
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
|
||||
return new AssetPageView(page, nextCursor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AssetView> find(UUID assetId) {
|
||||
return jdbcClient
|
||||
.sql(selectColumns() + " WHERE a.id = :id")
|
||||
.param("id", assetId)
|
||||
.query(JdbcAssetRepositoryAdapter::mapAsset)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AssetDetailView> findDetail(UUID assetId) {
|
||||
return find(assetId)
|
||||
.map(
|
||||
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssetView create(NewAsset asset, String principal) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
|
||||
+ " original_name, display_name, content_type, size_bytes, width, height,"
|
||||
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
|
||||
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
|
||||
// 계약의 Asset.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 생성 직후
|
||||
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 도 맞출 수 없다.
|
||||
+ " :decorative, 1, :principal, :principal)")
|
||||
.param("id", asset.id())
|
||||
.param("assetKey", asset.assetKey())
|
||||
.param("kind", asset.kind().name())
|
||||
.param("status", asset.managementStatus().name())
|
||||
.param("objectKey", asset.objectKey())
|
||||
.param("originalName", asset.originalFilename())
|
||||
.param("contentType", asset.mediaType())
|
||||
.param("size", asset.byteSize())
|
||||
.param("width", asset.width())
|
||||
.param("height", asset.height())
|
||||
.param("checksum", asset.checksumSha256())
|
||||
.param("altText", asset.altText())
|
||||
.param("decorative", asset.decorative())
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
return find(asset.id()).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AssetView> update(
|
||||
UUID assetId,
|
||||
long expectedVersion,
|
||||
AssetKindView kind,
|
||||
String altText,
|
||||
boolean altTextProvided,
|
||||
Boolean decorative,
|
||||
AssetManagementStatusView managementStatus,
|
||||
String principal) {
|
||||
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE asset SET"
|
||||
// 보내지 않은 필드는 그대로 둔다 — PUT 이지만 계약의 UpdateAssetCommand 는
|
||||
// expectedVersion 외 전부 optional 이라 부분 갱신 의미다.
|
||||
+ " asset_kind = COALESCE(:kind, asset_kind),"
|
||||
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
|
||||
+ " decorative = COALESCE(:decorative, decorative),"
|
||||
+ " management_status = COALESCE(:status, management_status),"
|
||||
+ " version = version + 1, updated_at = now(), updated_by = :principal"
|
||||
+ " WHERE id = :id AND version = :expectedVersion")
|
||||
.param("kind", kind == null ? null : kind.name())
|
||||
.param("altTextProvided", altTextProvided)
|
||||
.param("altText", altText)
|
||||
.param("decorative", decorative)
|
||||
.param("status", managementStatus == null ? null : managementStatus.name())
|
||||
.param("principal", principal)
|
||||
.param("id", assetId)
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.update();
|
||||
return updated == 0 ? Optional.empty() : find(assetId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> findObjectKey(UUID assetId) {
|
||||
return jdbcClient
|
||||
.sql("SELECT object_key FROM asset WHERE id = :id")
|
||||
.param("id", assetId)
|
||||
.query(String.class)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UUID assetId) {
|
||||
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
|
||||
}
|
||||
|
||||
private static String selectColumns() {
|
||||
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
|
||||
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
|
||||
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
|
||||
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
|
||||
+ " FROM asset a";
|
||||
}
|
||||
|
||||
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new AssetView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("asset_key"),
|
||||
AssetKindView.valueOf(rs.getString("asset_kind")),
|
||||
rs.getString("content_type"),
|
||||
rs.getString("original_name"),
|
||||
rs.getLong("size_bytes"),
|
||||
(Integer) rs.getObject("width"),
|
||||
(Integer) rs.getObject("height"),
|
||||
rs.getString("alt_text"),
|
||||
rs.getBoolean("decorative"),
|
||||
AssetManagementStatusView.valueOf(rs.getString("management_status")),
|
||||
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
|
||||
"/media/" + rs.getString("id"),
|
||||
rs.getInt("usage_count"),
|
||||
rs.getLong("version"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getTimestamp("updated_at").toInstant());
|
||||
}
|
||||
|
||||
private List<AssetUsageView> usagesOf(UUID assetId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
|
||||
+ " COALESCE(d.title, q.question, pd.title) AS title,"
|
||||
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
|
||||
+ " FROM asset_reference r"
|
||||
+ " LEFT JOIN document d ON d.id = r.owner_id"
|
||||
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
|
||||
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
|
||||
+ " WHERE r.asset_id = :id")
|
||||
.param("id", assetId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new AssetUsageView(
|
||||
rs.getObject("owner_id", UUID.class),
|
||||
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
|
||||
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
|
||||
"PUBLISHED".equals(rs.getString("reference_scope"))))
|
||||
.list();
|
||||
}
|
||||
|
||||
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
|
||||
private static RecordKind kindOf(String ownerType, String documentKind) {
|
||||
return switch (ownerType) {
|
||||
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
|
||||
case "DECISION" -> RecordKind.PROJECT_DECISION;
|
||||
default -> RecordKind.QUESTION;
|
||||
};
|
||||
}
|
||||
|
||||
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
|
||||
private boolean hasPublicationHistory(UUID assetId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT (first_published_at IS NOT NULL"
|
||||
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
|
||||
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
|
||||
+ " FROM asset WHERE id = :id")
|
||||
.param("id", assetId)
|
||||
.query(Boolean.class)
|
||||
.optional()
|
||||
.orElse(Boolean.FALSE));
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다.
|
||||
*
|
||||
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 의 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
|
||||
* {@code nextAction} 이 갈라진다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicationPageView list(ListPublicationsQuery query) {
|
||||
StringBuilder sql =
|
||||
new StringBuilder(
|
||||
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
|
||||
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
|
||||
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
|
||||
+ " FROM publication_event e"
|
||||
+ " JOIN publication p ON p.publication_id = e.publication_id"
|
||||
+ " LEFT JOIN publication_snapshot s"
|
||||
+ " ON s.publication_event_id = e.publication_event_id"
|
||||
+ " WHERE 1 = 1");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
if (query.type() != null) {
|
||||
sql.append(" AND e.event_type = :type");
|
||||
params.put("type", query.type().name());
|
||||
}
|
||||
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
|
||||
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
|
||||
params.put("before", Timestamp.from(query.beforeOccurredAt()));
|
||||
params.put("beforeId", query.beforeEventId());
|
||||
}
|
||||
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne");
|
||||
params.put("limitPlusOne", query.limit() + 1);
|
||||
|
||||
var spec = jdbcClient.sql(sql.toString());
|
||||
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||
spec = spec.param(param.getKey(), param.getValue());
|
||||
}
|
||||
|
||||
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
|
||||
boolean hasMore = rows.size() > query.limit();
|
||||
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||
|
||||
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
|
||||
List<PublicationListItemView> items = new ArrayList<>(page.size());
|
||||
for (Row row : page) {
|
||||
items.add(
|
||||
new PublicationListItemView(
|
||||
row.event(),
|
||||
row.publication(),
|
||||
summaries.get(row.event().documentId()),
|
||||
actionsFor(row)));
|
||||
}
|
||||
String nextCursor =
|
||||
hasMore
|
||||
? page.getLast().event().occurredAt()
|
||||
+ "|"
|
||||
+ page.getLast().event().publicationEventId()
|
||||
: null;
|
||||
return new PublicationPageView(items, nextCursor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||
+ " true AS snapshot_available,"
|
||||
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
|
||||
+ " FROM publication_snapshot s"
|
||||
+ " JOIN publication_event e"
|
||||
+ " ON e.publication_event_id = s.publication_event_id"
|
||||
+ " WHERE s.publication_event_id = :id")
|
||||
.param("id", publicationEventId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new PublicationSnapshotView(
|
||||
PublicationRowMappers.mapEvent(rs, rowNum),
|
||||
rs.getString("render_model"),
|
||||
rs.getString("content_format_version"),
|
||||
rs.getString("renderer_contract_version")))
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicationAggregateView> findById(UUID publicationId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||
+ " latest_event_id, public_path, updated_at FROM publication"
|
||||
+ " WHERE publication_id = :id")
|
||||
.param("id", publicationId)
|
||||
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||
.optional();
|
||||
}
|
||||
|
||||
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
|
||||
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
|
||||
jdbcClient
|
||||
.sql(
|
||||
StudioDocumentSql.documentProjectionCte()
|
||||
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
|
||||
.param("ids", ids)
|
||||
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
|
||||
.list()
|
||||
.forEach(summary -> summaries.put(summary.id(), summary));
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 할 수 있는 것만 담는다 — 화면이 눌러도 실패할 버튼을
|
||||
* 그리지 않게 하려는 값이다.
|
||||
*/
|
||||
private static List<PublicationActionView> actionsFor(Row row) {
|
||||
List<PublicationActionView> actions = new ArrayList<>();
|
||||
if (row.event().snapshotAvailable()) {
|
||||
actions.add(PublicationActionView.VIEW_SNAPSHOT);
|
||||
}
|
||||
if (row.event().sourcePublishedEventId() != null) {
|
||||
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
|
||||
}
|
||||
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
|
||||
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
|
||||
actions.add(PublicationActionView.UNPUBLISH);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
|
||||
|
||||
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
PublicationEventView event =
|
||||
new PublicationEventView(
|
||||
rs.getObject("publication_event_id", UUID.class),
|
||||
rs.getObject("publication_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
PublicationEventTypeView.valueOf(rs.getString("event_type")),
|
||||
rs.getTimestamp("occurred_at").toInstant(),
|
||||
rs.getLong("published_version"),
|
||||
rs.getObject("source_published_event_id", UUID.class),
|
||||
rs.getBoolean("snapshot_available"));
|
||||
PublicationAggregateView publication =
|
||||
new PublicationAggregateView(
|
||||
rs.getObject("publication_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||
rs.getLong("current_version"),
|
||||
rs.getLong("publication_revision"),
|
||||
rs.getObject("latest_event_id", UUID.class),
|
||||
rs.getString("public_path"),
|
||||
rs.getTimestamp("publication_updated_at").toInstant());
|
||||
return new Row(event, publication);
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.StudioError;
|
||||
import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* spec §7.5 의 게시 트랜잭션 10~19단계.
|
||||
*
|
||||
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리킨다. 첫
|
||||
* 게시는 publication INSERT → event INSERT → publication UPDATE 순서로 한 트랜잭션 안에서 끝나며, 그 순환을 허용하는 것이 V7 의
|
||||
* {@code DEFERRABLE INITIALLY DEFERRED} 다. 즉시 검사로 바꾸면 첫 게시가 구조적으로 불가능해진다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
|
||||
|
||||
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
|
||||
private static final short PAYLOAD_SCHEMA_VERSION = 1;
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Supplier<UUID> idGenerator;
|
||||
|
||||
/**
|
||||
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
|
||||
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
|
||||
*/
|
||||
@Autowired
|
||||
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this(jdbcClient, objectMapper, UUID::randomUUID);
|
||||
}
|
||||
|
||||
JdbcPublicationWriterAdapter(
|
||||
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicationAggregateView> lockCurrentPublication(
|
||||
RecordKind kind, UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||
+ " latest_event_id, public_path, updated_at FROM publication"
|
||||
+ " WHERE source_kind = :kind AND source_id = :id FOR UPDATE")
|
||||
.param("kind", kind.name())
|
||||
.param("id", documentId)
|
||||
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublishResultView publish(PublishRequest request) {
|
||||
requireTransaction("publish");
|
||||
Optional<PublicationAggregateView> existing =
|
||||
lockCurrentPublication(request.kind(), request.documentId());
|
||||
|
||||
UUID publicationId =
|
||||
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
|
||||
UUID eventId = idGenerator.get();
|
||||
PublicationEventTypeView eventType =
|
||||
existing.isEmpty()
|
||||
? PublicationEventTypeView.PUBLISHED
|
||||
: PublicationEventTypeView.REPUBLISHED;
|
||||
|
||||
if (existing.isEmpty()) {
|
||||
// 10 이전: aggregate 를 먼저 만든다. latest_event_id 는 아직 없는 event 를 가리키지만
|
||||
// 지연 검사라 커밋 시점에만 확인된다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
|
||||
+ " published_version, publication_revision, latest_event_id, public_path)"
|
||||
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
|
||||
+ " :publicPath)")
|
||||
.param("publicationId", publicationId)
|
||||
.param("kind", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.param("version", request.version())
|
||||
.param("eventId", eventId)
|
||||
.param("publicPath", request.publicPath())
|
||||
.update();
|
||||
}
|
||||
|
||||
// 10. Event
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
|
||||
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
|
||||
+ " created_by)"
|
||||
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
|
||||
+ " :idempotencyKey, :principal)")
|
||||
.param("eventId", eventId)
|
||||
.param("publicationId", publicationId)
|
||||
.param("kind", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.param("type", eventType.name())
|
||||
.param("version", request.version())
|
||||
.param("idempotencyKey", request.idempotencyKey())
|
||||
.param("principal", request.principal())
|
||||
.update();
|
||||
|
||||
// 11. Snapshot — 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
|
||||
+ " content_format_version, renderer_contract_version, asset_manifest)"
|
||||
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
|
||||
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
|
||||
.param("eventId", eventId)
|
||||
.param("renderModel", request.renderModelJson())
|
||||
.param("contentFormatVersion", request.contentFormatVersion())
|
||||
.param("rendererContractVersion", request.rendererContractVersion())
|
||||
.param("assetManifest", manifestJson(request.assetManifest()))
|
||||
.update();
|
||||
|
||||
upsertProjection(request);
|
||||
replaceRoute(request);
|
||||
replaceProjectLink(request);
|
||||
replacePublishedAssetReferences(request);
|
||||
markAssetsFirstPublished(request);
|
||||
|
||||
// 17. aggregate 갱신
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
|
||||
+ " publication_revision = publication_revision + :bump,"
|
||||
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
|
||||
+ " WHERE publication_id = :publicationId")
|
||||
.param("version", request.version())
|
||||
// 첫 게시는 INSERT 가 이미 revision 1 을 넣었다. 여기서 또 올리면 클라이언트가 받은 값과
|
||||
// 다음 unpublish 가 요구하는 값이 어긋난다.
|
||||
.param("bump", existing.isEmpty() ? 0 : 1)
|
||||
.param("eventId", eventId)
|
||||
.param("publicPath", request.publicPath())
|
||||
.param("publicationId", publicationId)
|
||||
.update();
|
||||
|
||||
// 18. Document publish metadata
|
||||
markSourcePublished(request);
|
||||
|
||||
return result(publicationId, eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublishResultView unpublish(UnpublishRequest request) {
|
||||
requireTransaction("unpublish");
|
||||
UUID lastPublishedEventId =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_event_id FROM publication_event"
|
||||
+ " WHERE publication_id = :publicationId"
|
||||
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
|
||||
+ " ORDER BY occurred_at DESC LIMIT 1")
|
||||
.param("publicationId", request.publicationId())
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
StudioException.of(
|
||||
StudioError.PUBLICATION_CONFLICT,
|
||||
"this publication has no published event to withdraw"));
|
||||
|
||||
UUID eventId = idGenerator.get();
|
||||
// UNPUBLISHED Event 는 자체 snapshot 을 만들지 않고 마지막 공개 Snapshot 을 참조한다(V7 주석).
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
|
||||
+ " source_id, event_type, published_version, source_published_event_id,"
|
||||
+ " occurred_at, created_by)"
|
||||
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
|
||||
+ " p.published_version, :sourceEventId, now(), :principal"
|
||||
+ " FROM publication p WHERE p.publication_id = :publicationId")
|
||||
.param("eventId", eventId)
|
||||
.param("sourceEventId", lastPublishedEventId)
|
||||
.param("principal", request.principal())
|
||||
.param("publicationId", request.publicationId())
|
||||
.update();
|
||||
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE publication SET status = 'UNPUBLISHED',"
|
||||
+ " publication_revision = publication_revision + 1,"
|
||||
+ " latest_event_id = :eventId, updated_at = now()"
|
||||
+ " WHERE publication_id = :publicationId"
|
||||
+ " AND publication_revision = :expectedRevision")
|
||||
.param("eventId", eventId)
|
||||
.param("publicationId", request.publicationId())
|
||||
.param("expectedRevision", request.expectedRevision())
|
||||
.update();
|
||||
if (updated == 0) {
|
||||
throw StudioException.of(
|
||||
StudioError.PUBLICATION_CONFLICT,
|
||||
"the publication revision changed while withdrawing it");
|
||||
}
|
||||
|
||||
// Projection ACTIVE -> WITHDRAWN. route 는 유지한다 — 주소가 사라지면 링크가 끊긴다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
|
||||
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
|
||||
.param("type", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
|
||||
// Working copy 는 다시 초안으로 돌아간다.
|
||||
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
|
||||
jdbcClient
|
||||
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
}
|
||||
return result(request.publicationId(), eventId);
|
||||
}
|
||||
|
||||
/** 12. 공개 projection upsert. */
|
||||
private void upsertProjection(PublishRequest request) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
|
||||
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||
+ " published_at, updated_at, navigation_path)"
|
||||
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
|
||||
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
|
||||
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
|
||||
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
|
||||
+ " source_version = EXCLUDED.source_version,"
|
||||
+ " publication_state = 'ACTIVE',"
|
||||
+ " visibility = EXCLUDED.visibility,"
|
||||
+ " title = EXCLUDED.title,"
|
||||
+ " summary = EXCLUDED.summary,"
|
||||
+ " state_code = EXCLUDED.state_code,"
|
||||
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
|
||||
+ " payload = EXCLUDED.payload,"
|
||||
+ " body_plain_text = EXCLUDED.body_plain_text,"
|
||||
+ " search_text = EXCLUDED.search_text,"
|
||||
+ " content_hash = EXCLUDED.content_hash,"
|
||||
+ " updated_at = now(),"
|
||||
+ " navigation_path = EXCLUDED.navigation_path")
|
||||
.param("type", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.param("version", request.version())
|
||||
.param("title", request.title())
|
||||
.param("summary", request.summary())
|
||||
.param("stateCode", request.stateCode())
|
||||
.param("topicId", request.topicId())
|
||||
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
|
||||
.param("payload", request.renderModelJson())
|
||||
.param("bodyPlainText", request.bodyPlainText())
|
||||
.param(
|
||||
"searchText",
|
||||
String.join(
|
||||
" ",
|
||||
nullToEmpty(request.title()),
|
||||
nullToEmpty(request.summary()),
|
||||
nullToEmpty(request.bodyPlainText())))
|
||||
.param("contentHash", sha256(request.renderModelJson()))
|
||||
.param("navigationPath", request.publicPath())
|
||||
.update();
|
||||
}
|
||||
|
||||
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
|
||||
private void replaceRoute(PublishRequest request) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE public_route SET route_role = 'ALIAS'"
|
||||
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
|
||||
.param("type", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.param("slug", slugOf(request.publicPath()))
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
|
||||
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
|
||||
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
|
||||
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
|
||||
.param("type", request.kind().name())
|
||||
.param("slug", slugOf(request.publicPath()))
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
}
|
||||
|
||||
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
|
||||
private void replaceProjectLink(PublishRequest request) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"DELETE FROM public_resource_project_link"
|
||||
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
|
||||
.param("type", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
if (request.projectId() == null) {
|
||||
return;
|
||||
}
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
|
||||
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
|
||||
+ " ON CONFLICT (resource_type, resource_id, project_id)"
|
||||
+ " DO UPDATE SET relation_type = 'PRIMARY'")
|
||||
.param("type", request.kind().name())
|
||||
.param("id", request.documentId())
|
||||
.param("projectId", request.projectId())
|
||||
.update();
|
||||
}
|
||||
|
||||
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
|
||||
private void replacePublishedAssetReferences(PublishRequest request) {
|
||||
String ownerType = ownerTypeOf(request.kind());
|
||||
jdbcClient
|
||||
.sql(
|
||||
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
|
||||
+ " AND reference_scope = 'PUBLISHED'")
|
||||
.param("ownerType", ownerType)
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
for (AssetManifestEntry entry : request.assetManifest()) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
|
||||
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
|
||||
+ " ON CONFLICT DO NOTHING")
|
||||
.param("assetId", entry.assetId())
|
||||
.param("ownerType", ownerType)
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
|
||||
private void markAssetsFirstPublished(PublishRequest request) {
|
||||
for (AssetManifestEntry entry : request.assetManifest()) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE asset SET first_published_at = now()"
|
||||
+ " WHERE id = :assetId AND first_published_at IS NULL")
|
||||
.param("assetId", entry.assetId())
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
/** 18. source 쪽 게시 메타데이터. */
|
||||
private void markSourcePublished(PublishRequest request) {
|
||||
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 유형이 늘면 컴파일러가
|
||||
// 여기서 막아 준다(문이면 조용히 아무것도 안 하고 지나간다).
|
||||
int updated =
|
||||
switch (request.kind()) {
|
||||
case CASE, REFERENCE ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE document SET workflow_status = 'PUBLISHED',"
|
||||
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||
+ " last_published_at = now() WHERE id = :id")
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
case QUESTION ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE open_question SET"
|
||||
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||
+ " last_published_at = now() WHERE id = :id")
|
||||
.param("id", request.documentId())
|
||||
.update();
|
||||
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 이 소유하므로
|
||||
// 여기서 억지로 컬럼을 만들지 않는다.
|
||||
case PROJECT_DECISION -> 0;
|
||||
};
|
||||
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
|
||||
throw StudioException.of(
|
||||
StudioError.DOCUMENT_NOT_FOUND,
|
||||
"the source record disappeared while publishing " + request.documentId());
|
||||
}
|
||||
}
|
||||
|
||||
private PublishResultView result(UUID publicationId, UUID eventId) {
|
||||
PublicationAggregateView aggregate =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_id, source_id, status, published_version,"
|
||||
+ " publication_revision, latest_event_id, public_path, updated_at"
|
||||
+ " FROM publication WHERE publication_id = :id")
|
||||
.param("id", publicationId)
|
||||
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||
.single();
|
||||
PublicationEventView event =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
|
||||
+ " FROM publication_event e"
|
||||
+ " LEFT JOIN publication_snapshot s"
|
||||
+ " ON s.publication_event_id = e.publication_event_id"
|
||||
+ " WHERE e.publication_event_id = :id")
|
||||
.param("id", eventId)
|
||||
.query(PublicationRowMappers::mapEvent)
|
||||
.single();
|
||||
return new PublishResultView(aggregate, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
|
||||
*
|
||||
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그
|
||||
* 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
|
||||
* <b>트랜잭션 끝</b>에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다.
|
||||
*
|
||||
* <p>이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게
|
||||
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
|
||||
*/
|
||||
private static void requireTransaction(String operation) {
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
throw new IllegalStateException(
|
||||
"publication "
|
||||
+ operation
|
||||
+ " must run inside an active transaction: publication and publication_event"
|
||||
+ " reference each other, and that cycle only resolves at commit through"
|
||||
+ " fk_publication_latest_event's deferred check");
|
||||
}
|
||||
}
|
||||
|
||||
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
|
||||
throws java.sql.SQLException {
|
||||
return new PublicationAggregateView(
|
||||
rs.getObject("publication_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||
rs.getLong("published_version"),
|
||||
rs.getLong("publication_revision"),
|
||||
rs.getObject("latest_event_id", UUID.class),
|
||||
rs.getString("public_path"),
|
||||
rs.getTimestamp("updated_at").toInstant());
|
||||
}
|
||||
|
||||
private String manifestJson(List<AssetManifestEntry> manifest) {
|
||||
ArrayNode array = objectMapper.createArrayNode();
|
||||
for (AssetManifestEntry entry : manifest) {
|
||||
ObjectNode node = array.addObject();
|
||||
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
|
||||
node.put("assetKey", entry.assetKey());
|
||||
node.put("mediaType", entry.mediaType());
|
||||
node.put("publicPath", entry.publicPath());
|
||||
node.put("width", entry.width());
|
||||
node.put("height", entry.height());
|
||||
node.put("decorative", entry.decorative());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(array);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to serialise a publication asset manifest", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
|
||||
private static String ownerTypeOf(RecordKind kind) {
|
||||
return switch (kind) {
|
||||
case CASE, REFERENCE -> "DOCUMENT";
|
||||
case QUESTION -> "QUESTION";
|
||||
case PROJECT_DECISION -> "DECISION";
|
||||
};
|
||||
}
|
||||
|
||||
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
|
||||
private static String slugOf(String publicPath) {
|
||||
if (publicPath == null || publicPath.isBlank()) {
|
||||
throw StudioException.of(
|
||||
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
|
||||
}
|
||||
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
|
||||
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
|
||||
final class PublicationRowMappers {
|
||||
|
||||
private PublicationRowMappers() {}
|
||||
|
||||
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PublicationEventView(
|
||||
rs.getObject("publication_event_id", UUID.class),
|
||||
rs.getObject("publication_id", UUID.class),
|
||||
rs.getObject("source_id", UUID.class),
|
||||
PublicationEventTypeView.valueOf(rs.getString("event_type")),
|
||||
rs.getTimestamp("occurred_at").toInstant(),
|
||||
rs.getLong("published_version"),
|
||||
rs.getObject("source_published_event_id", UUID.class),
|
||||
rs.getBoolean("snapshot_available"));
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* {@code CASE} / {@code REFERENCE} 편집본. 둘 다 {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
|
||||
* 07장).
|
||||
*/
|
||||
final class DocumentWorkingCopyStore {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final StudioRelationStore relations;
|
||||
private final StudioJson json;
|
||||
private final Supplier<UUID> idGenerator;
|
||||
private final ProjectLinkStore projectLinks;
|
||||
|
||||
DocumentWorkingCopyStore(
|
||||
JdbcClient jdbcClient,
|
||||
StudioRelationStore relations,
|
||||
StudioJson json,
|
||||
Supplier<UUID> idGenerator,
|
||||
ProjectLinkStore projectLinks) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.relations = relations;
|
||||
this.json = json;
|
||||
this.idGenerator = idGenerator;
|
||||
this.projectLinks = projectLinks;
|
||||
}
|
||||
|
||||
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
|
||||
+ " d.body_markdown, d.last_verified_at,"
|
||||
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
|
||||
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
|
||||
+ " FROM document d"
|
||||
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
|
||||
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
|
||||
+ " WHERE d.id = :id AND d.document_type = :type")
|
||||
.param("id", id)
|
||||
.param("type", kind.name())
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID documentId = rs.getObject("id", UUID.class);
|
||||
WorkingCopyBaseInput base =
|
||||
new WorkingCopyBaseInput(
|
||||
kind,
|
||||
rs.getString("title"),
|
||||
slugFromColumn(rs.getString("slug")),
|
||||
orEmpty(rs.getString("summary")),
|
||||
rs.getObject("primary_topic_id", UUID.class),
|
||||
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
|
||||
relations.findBySource(kind, documentId));
|
||||
long version = rs.getLong("version");
|
||||
var updatedAt = instant(rs, "updated_at");
|
||||
if (kind == RecordKind.CASE) {
|
||||
return (WorkingCopyView)
|
||||
new WorkingCopyView.CaseWorkingCopyView(
|
||||
documentId,
|
||||
version,
|
||||
updatedAt,
|
||||
base,
|
||||
orEmpty(rs.getString("problem_summary")),
|
||||
orEmpty(rs.getString("conclusion_summary")),
|
||||
orEmpty(rs.getString("environment")),
|
||||
orEmpty(rs.getString("reproduction")),
|
||||
dateFromTimestamp(rs, "last_verified_at"),
|
||||
orEmpty(rs.getString("body_markdown")));
|
||||
}
|
||||
return (WorkingCopyView)
|
||||
new WorkingCopyView.ReferenceWorkingCopyView(
|
||||
documentId,
|
||||
version,
|
||||
updatedAt,
|
||||
base,
|
||||
orEmpty(rs.getString("scope_summary")),
|
||||
json.rulesFromJson(rs.getString("rules")),
|
||||
json.orderedTextFromJson(rs.getString("applies_to")),
|
||||
json.orderedTextFromJson(rs.getString("excluded_scope")),
|
||||
json.orderedTextFromJson(rs.getString("examples")),
|
||||
dateFromTimestamp(rs, "last_verified_at"));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
UUID create(WorkingCopyInputView input, String principal) {
|
||||
RecordKind kind = input.kind();
|
||||
UUID id = idGenerator.get();
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
|
||||
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
|
||||
// 계약의 WorkingCopyBase.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면
|
||||
// 생성 직후 응답이 계약을 위반한다.
|
||||
+ " 1, :principal, :principal)")
|
||||
.param("id", id)
|
||||
.param("type", kind.name())
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("title", orEmpty(base.title()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("body", bodyMarkdownOf(input))
|
||||
.param("topicId", base.topicId())
|
||||
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
|
||||
insertDetail(id, input);
|
||||
relations.replace(kind, id, base.relations());
|
||||
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 낙관적 잠금 저장.
|
||||
*
|
||||
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
|
||||
*/
|
||||
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
|
||||
RecordKind kind = input.kind();
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
|
||||
+ " body_markdown = :body, primary_topic_id = :topicId,"
|
||||
+ " last_verified_at = :verifiedAt, version = version + 1,"
|
||||
+ " updated_at = now(), updated_by = :principal"
|
||||
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("title", orEmpty(base.title()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("body", bodyMarkdownOf(input))
|
||||
.param("topicId", base.topicId())
|
||||
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
|
||||
.param("principal", principal)
|
||||
.param("id", id)
|
||||
.param("type", kind.name())
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.update();
|
||||
if (updated == 0) {
|
||||
return false;
|
||||
}
|
||||
updateDetail(id, input);
|
||||
relations.replace(kind, id, base.relations());
|
||||
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
|
||||
return true;
|
||||
}
|
||||
|
||||
private void insertDetail(UUID id, WorkingCopyInputView input) {
|
||||
switch (input) {
|
||||
case WorkingCopyInputView.CaseInputView caseInput ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
|
||||
+ " conclusion_summary, environment, reproduction)"
|
||||
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
|
||||
.param("id", id)
|
||||
.param("problem", orEmpty(caseInput.problem()))
|
||||
.param("conclusion", orEmpty(caseInput.conclusion()))
|
||||
.param("environment", orEmpty(caseInput.environment()))
|
||||
.param("reproduction", orEmpty(caseInput.reproduction()))
|
||||
.update();
|
||||
case WorkingCopyInputView.ReferenceInputView reference ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
|
||||
+ " rules, applies_to, excluded_scope, examples)"
|
||||
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
|
||||
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
|
||||
+ " CAST(:examples AS jsonb))")
|
||||
.param("id", id)
|
||||
.param("purpose", orEmpty(reference.purpose()))
|
||||
.param("rules", json.rulesToJson(reference.rules()))
|
||||
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
|
||||
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
|
||||
.param("examples", json.orderedTextToJson(reference.examples()))
|
||||
.update();
|
||||
default ->
|
||||
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDetail(UUID id, WorkingCopyInputView input) {
|
||||
switch (input) {
|
||||
case WorkingCopyInputView.CaseInputView caseInput ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE case_detail SET problem_summary = :problem,"
|
||||
+ " conclusion_summary = :conclusion, environment = :environment,"
|
||||
+ " reproduction = :reproduction WHERE document_id = :id")
|
||||
.param("id", id)
|
||||
.param("problem", orEmpty(caseInput.problem()))
|
||||
.param("conclusion", orEmpty(caseInput.conclusion()))
|
||||
.param("environment", orEmpty(caseInput.environment()))
|
||||
.param("reproduction", orEmpty(caseInput.reproduction()))
|
||||
.update();
|
||||
case WorkingCopyInputView.ReferenceInputView reference ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE reference_detail SET scope_summary = :purpose,"
|
||||
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
|
||||
+ " excluded_scope = CAST(:exceptions AS jsonb),"
|
||||
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
|
||||
.param("id", id)
|
||||
.param("purpose", orEmpty(reference.purpose()))
|
||||
.param("rules", json.rulesToJson(reference.rules()))
|
||||
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
|
||||
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
|
||||
.param("examples", json.orderedTextToJson(reference.examples()))
|
||||
.update();
|
||||
default ->
|
||||
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
|
||||
private static String bodyMarkdownOf(WorkingCopyInputView input) {
|
||||
return input instanceof WorkingCopyInputView.CaseInputView caseInput
|
||||
? orEmpty(caseInput.bodyMarkdown())
|
||||
: "";
|
||||
}
|
||||
|
||||
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
|
||||
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
|
||||
return switch (input) {
|
||||
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
|
||||
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
List<RelationView> relationsOf(RecordKind kind, UUID id) {
|
||||
return relations.findBySource(kind, id);
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.StudioError;
|
||||
import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 계약의 통합 {@code WorkingCopy}를 네 source aggregate로 dispatch한다.
|
||||
*
|
||||
* <p>공통 CRUD repository가 아니다 — {@code kind}마다 소유 테이블이 다르고, 그 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은
|
||||
* "어느 저장소로 보낼지" 뿐이다.
|
||||
*
|
||||
* <p>JPA 엔티티가 아니라 {@code JdbcClient}를 쓴다. 이 네 aggregate는 Studio 저장 경로에서만 쓰이고, 낙관적 잠금은 {@code UPDATE
|
||||
* ... WHERE version = :expectedVersion}의 갱신 행 수로 정확히 같은 의미를 얻는다 — 여덟 개 넘는 테이블에 엔티티와 매핑을 세우는 비용에
|
||||
* 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA 가 필요해지면 이 어댑터만 바뀐다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcWorkingCopyRepositoryAdapter implements WorkingCopyRepositoryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final DocumentWorkingCopyStore documents;
|
||||
private final QuestionWorkingCopyStore questions;
|
||||
private final ProjectDecisionWorkingCopyStore decisions;
|
||||
|
||||
/**
|
||||
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
|
||||
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
|
||||
*/
|
||||
@Autowired
|
||||
public JdbcWorkingCopyRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this(jdbcClient, objectMapper, UUID::randomUUID);
|
||||
}
|
||||
|
||||
JdbcWorkingCopyRepositoryAdapter(
|
||||
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
StudioJson json = new StudioJson(objectMapper);
|
||||
StudioRelationStore relations = new StudioRelationStore(jdbcClient, idGenerator);
|
||||
ProjectLinkStore projectLinks = new ProjectLinkStore(jdbcClient);
|
||||
this.documents =
|
||||
new DocumentWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
|
||||
this.questions =
|
||||
new QuestionWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
|
||||
this.decisions = new ProjectDecisionWorkingCopyStore(jdbcClient, relations, json, idGenerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약상 {@code documentId}는 source aggregate id 그대로다 — 어느 테이블에 있는지 먼저 찾아야 한다 (spec §7.2
|
||||
* StudioDocumentLocator). 세 테이블을 UNION 으로 한 번에 본다.
|
||||
*/
|
||||
@Override
|
||||
public Optional<RecordKind> findKind(UUID documentId) {
|
||||
if (documentId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT document_type AS kind FROM document WHERE id = :id"
|
||||
+ " UNION ALL SELECT 'QUESTION' FROM open_question WHERE id = :id"
|
||||
+ " UNION ALL SELECT 'PROJECT_DECISION' FROM project_decision WHERE id = :id")
|
||||
.param("id", documentId)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.map(RecordKind::valueOf);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<WorkingCopyView> find(UUID documentId) {
|
||||
return findKind(documentId).flatMap(kind -> load(kind, documentId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorkingCopyView create(WorkingCopyInputView input, String principal) {
|
||||
UUID id =
|
||||
switch (input) {
|
||||
case WorkingCopyInputView.CaseInputView ignored -> documents.create(input, principal);
|
||||
case WorkingCopyInputView.ReferenceInputView ignored ->
|
||||
documents.create(input, principal);
|
||||
case WorkingCopyInputView.QuestionInputView question ->
|
||||
questions.create(question, principal);
|
||||
case WorkingCopyInputView.ProjectDecisionInputView decision ->
|
||||
decisions.create(decision, principal);
|
||||
};
|
||||
return load(input.kind(), id)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
StudioException.of(
|
||||
StudioError.STUDIO_UNAVAILABLE,
|
||||
"the working copy "
|
||||
+ id
|
||||
+ " could not be read back right after it was created"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<WorkingCopyView> save(
|
||||
UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal) {
|
||||
boolean saved =
|
||||
switch (input) {
|
||||
case WorkingCopyInputView.CaseInputView ignored ->
|
||||
documents.save(documentId, expectedVersion, input, principal);
|
||||
case WorkingCopyInputView.ReferenceInputView ignored ->
|
||||
documents.save(documentId, expectedVersion, input, principal);
|
||||
case WorkingCopyInputView.QuestionInputView question ->
|
||||
questions.save(documentId, expectedVersion, question, principal);
|
||||
case WorkingCopyInputView.ProjectDecisionInputView decision ->
|
||||
decisions.save(documentId, expectedVersion, decision, principal);
|
||||
};
|
||||
return saved ? load(input.kind(), documentId) : Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<WorkingCopyView> load(RecordKind kind, UUID id) {
|
||||
return switch (kind) {
|
||||
case CASE, REFERENCE -> documents.find(kind, id);
|
||||
case QUESTION -> questions.find(id);
|
||||
case PROJECT_DECISION -> decisions.find(id);
|
||||
};
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.StudioError;
|
||||
import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* {@code PROJECT_DECISION} 편집본 ({@code project_decision}).
|
||||
*
|
||||
* <p>계약의 {@code ADOPTED}는 Domain의 {@code ACCEPTED}다(ADR-003) — UI 용어 때문에 Domain enum을 바꾸지 않고 여기서
|
||||
* 변환한다. {@code supersede}/{@code reject}는 secondary management 계약이 소유하므로 이 저장 경로가 그 두 상태를 만들지도,
|
||||
* 건드리지도 않는다.
|
||||
*/
|
||||
final class ProjectDecisionWorkingCopyStore {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final StudioRelationStore relations;
|
||||
private final StudioJson json;
|
||||
private final Supplier<UUID> idGenerator;
|
||||
|
||||
ProjectDecisionWorkingCopyStore(
|
||||
JdbcClient jdbcClient,
|
||||
StudioRelationStore relations,
|
||||
StudioJson json,
|
||||
Supplier<UUID> idGenerator) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.relations = relations;
|
||||
this.json = json;
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
Optional<WorkingCopyView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, version, updated_at, title, slug, summary, primary_topic_id, project_id,"
|
||||
+ " decision_status, decided_at, statement, rationale_markdown, consequences"
|
||||
+ " FROM project_decision WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID decisionId = rs.getObject("id", UUID.class);
|
||||
WorkingCopyBaseInput base =
|
||||
new WorkingCopyBaseInput(
|
||||
RecordKind.PROJECT_DECISION,
|
||||
orEmpty(rs.getString("title")),
|
||||
slugFromColumn(rs.getString("slug")),
|
||||
orEmpty(rs.getString("summary")),
|
||||
rs.getObject("primary_topic_id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
relations.findBySource(RecordKind.PROJECT_DECISION, decisionId));
|
||||
return (WorkingCopyView)
|
||||
new WorkingCopyView.ProjectDecisionWorkingCopyView(
|
||||
decisionId,
|
||||
rs.getLong("version"),
|
||||
instant(rs, "updated_at"),
|
||||
base,
|
||||
toContractStatus(rs.getString("decision_status")),
|
||||
dateFromTimestamp(rs, "decided_at"),
|
||||
orEmpty(rs.getString("statement")),
|
||||
orEmpty(rs.getString("rationale_markdown")),
|
||||
json.orderedTextFromJson(rs.getString("consequences")));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
UUID create(WorkingCopyInputView.ProjectDecisionInputView input, String principal) {
|
||||
requireDecidedOnWhenAdopted(input);
|
||||
UUID id = idGenerator.get();
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_decision (id, project_id, title, slug, summary,"
|
||||
+ " primary_topic_id, statement, rationale_markdown, consequences,"
|
||||
+ " decision_status, decided_at, version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :projectId, :title, :slug, :summary, :topicId, :statement,"
|
||||
+ " :rationale, CAST(:consequences AS jsonb), :status, :decidedAt, 1,"
|
||||
+ " :principal, :principal)")
|
||||
.param("id", id)
|
||||
.param("projectId", base.projectId())
|
||||
.param("title", orEmpty(base.title()))
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("topicId", base.topicId())
|
||||
.param("statement", orEmpty(input.statement()))
|
||||
.param("rationale", orEmpty(input.rationale()))
|
||||
.param("consequences", json.orderedTextToJson(input.consequences()))
|
||||
.param("status", toDomainStatus(input.decisionStatus()))
|
||||
.param("decidedAt", dateToTimestamp(input.decidedOn()))
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
|
||||
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
|
||||
return id;
|
||||
}
|
||||
|
||||
boolean save(
|
||||
UUID id,
|
||||
long expectedVersion,
|
||||
WorkingCopyInputView.ProjectDecisionInputView input,
|
||||
String principal) {
|
||||
requireDecidedOnWhenAdopted(input);
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE project_decision SET project_id = :projectId, title = :title,"
|
||||
+ " slug = :slug, summary = :summary, primary_topic_id = :topicId,"
|
||||
+ " statement = :statement, rationale_markdown = :rationale,"
|
||||
+ " consequences = CAST(:consequences AS jsonb),"
|
||||
// SUPERSEDED/REJECTED 는 secondary management 계약이 소유한다. Studio 저장이
|
||||
// 그 상태를 PROPOSED/ACCEPTED 로 되돌리면 그쪽 lifecycle 이 조용히 무효화된다.
|
||||
+ " decision_status = CASE WHEN decision_status IN ('PROPOSED', 'ACCEPTED')"
|
||||
+ " THEN :status ELSE decision_status END,"
|
||||
+ " decided_at = :decidedAt,"
|
||||
+ " version = version + 1, updated_at = now(), updated_by = :principal"
|
||||
+ " WHERE id = :id AND version = :expectedVersion")
|
||||
.param("projectId", base.projectId())
|
||||
.param("title", orEmpty(base.title()))
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("topicId", base.topicId())
|
||||
.param("statement", orEmpty(input.statement()))
|
||||
.param("rationale", orEmpty(input.rationale()))
|
||||
.param("consequences", json.orderedTextToJson(input.consequences()))
|
||||
.param("status", toDomainStatus(input.decisionStatus()))
|
||||
.param("decidedAt", dateToTimestamp(input.decidedOn()))
|
||||
.param("principal", principal)
|
||||
.param("id", id)
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.update();
|
||||
if (updated == 0) {
|
||||
return false;
|
||||
}
|
||||
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code ck_project_decision_status_fields}는 {@code ACCEPTED}에 {@code decided_at}을 요구한다. 값을 지어내
|
||||
* 채우면 사용자가 정하지 않은 날짜가 기록되고, 그냥 보내면 DB 제약 위반이 500으로 나간다 — 무엇이 빠졌는지 알려주고 거절한다.
|
||||
*/
|
||||
private static void requireDecidedOnWhenAdopted(
|
||||
WorkingCopyInputView.ProjectDecisionInputView input) {
|
||||
if (input.decisionStatus() == DecisionStatusView.ADOPTED && input.decidedOn() == null) {
|
||||
throw StudioException.of(
|
||||
StudioError.REQUEST_VALIDATION_FAILED,
|
||||
"document.decidedOn is required when decisionStatus is ADOPTED");
|
||||
}
|
||||
}
|
||||
|
||||
private static String toDomainStatus(DecisionStatusView status) {
|
||||
return status == DecisionStatusView.ADOPTED ? "ACCEPTED" : "PROPOSED";
|
||||
}
|
||||
|
||||
private static DecisionStatusView toContractStatus(String domainStatus) {
|
||||
return switch (domainStatus) {
|
||||
case "ACCEPTED" -> DecisionStatusView.ADOPTED;
|
||||
case "PROPOSED" -> DecisionStatusView.PROPOSED;
|
||||
// SUPERSEDED / REJECTED 는 계약의 두 값 어디에도 대응하지 않는다. 계약은 null 을 허용하므로
|
||||
// 억지로 가장 가까운 값으로 접지 않고 "이 축약 view 로는 표현할 수 없음"을 null 로 알린다.
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* 계약의 단일 {@code projectId}를 설계 스키마의 링크 테이블({@code project_document_link} / {@code
|
||||
* project_question_link})에 옮긴다.
|
||||
*
|
||||
* <p>링크 테이블은 한 문서가 여러 프로젝트에 붙는 것을 허용하지만 Studio 편집기는 프로젝트 하나만 다룬다. Studio가 소유하는 것은 {@code PRIMARY}
|
||||
* 링크 하나뿐이며, {@code RELATED} 링크는 건드리지 않는다 — 그건 다른 화면의 데이터이고 Studio 저장이 지워도 되는 것이 아니다.
|
||||
*/
|
||||
final class ProjectLinkStore {
|
||||
|
||||
private static final String PRIMARY = "PRIMARY";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
ProjectLinkStore(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
Optional<UUID> findPrimaryProjectForDocument(UUID documentId) {
|
||||
return findPrimary("project_document_link", "document_id", documentId);
|
||||
}
|
||||
|
||||
Optional<UUID> findPrimaryProjectForQuestion(UUID questionId) {
|
||||
return findPrimary("project_question_link", "question_id", questionId);
|
||||
}
|
||||
|
||||
void setPrimaryProjectForDocument(UUID documentId, UUID projectId) {
|
||||
setPrimary("project_document_link", "document_id", documentId, projectId);
|
||||
}
|
||||
|
||||
void setPrimaryProjectForQuestion(UUID questionId, UUID projectId) {
|
||||
setPrimary("project_question_link", "question_id", questionId, projectId);
|
||||
}
|
||||
|
||||
private Optional<UUID> findPrimary(String table, String column, UUID id) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT project_id FROM "
|
||||
+ table
|
||||
+ " WHERE "
|
||||
+ column
|
||||
+ " = :id AND relation_type = :type")
|
||||
.param("id", id)
|
||||
.param("type", PRIMARY)
|
||||
.query(UUID.class)
|
||||
.optional();
|
||||
}
|
||||
|
||||
private void setPrimary(String table, String column, UUID id, UUID projectId) {
|
||||
jdbcClient
|
||||
.sql("DELETE FROM " + table + " WHERE " + column + " = :id AND relation_type = :type")
|
||||
.param("id", id)
|
||||
.param("type", PRIMARY)
|
||||
.update();
|
||||
if (projectId == null) {
|
||||
return;
|
||||
}
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO "
|
||||
+ table
|
||||
+ " (project_id, "
|
||||
+ column
|
||||
+ ", relation_type)"
|
||||
+ " VALUES (:projectId, :id, :type)"
|
||||
// PK 는 (project_id, <column>) 이라 같은 쌍이 RELATED 로 이미 있으면 INSERT 가 깨진다.
|
||||
// Studio 가 소유하는 것은 PRIMARY 이므로 그 경우 관계 종류를 올려준다.
|
||||
+ " ON CONFLICT (project_id, "
|
||||
+ column
|
||||
+ ") DO UPDATE SET relation_type = :type")
|
||||
.param("projectId", projectId)
|
||||
.param("id", id)
|
||||
.param("type", PRIMARY)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
|
||||
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.StudioError;
|
||||
import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
|
||||
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
|
||||
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* {@code QUESTION} 편집본 ({@code open_question} + {@code question_point}).
|
||||
*
|
||||
* <p>계약의 {@code questionStatus}는 Domain lifecycle의 축약 view다(ADR-003) — {@code OPEN}을 받았다고 Domain의
|
||||
* {@code INVESTIGATING}/{@code PAUSED}를 덮어쓰지 않는다. 그렇게 하면 조사 이력이 사라진다.
|
||||
*/
|
||||
final class QuestionWorkingCopyStore {
|
||||
|
||||
/**
|
||||
* 계약의 {@code QuestionResolution}에는 resolution_type이 없는데 {@code
|
||||
* ck_question_resolution_consistency}는 RESOLVED에 그 값을 요구한다. Studio 편집으로 해결 처리된 질문은 "판단을 내렸다"로
|
||||
* 기록한다 — 나머지 세 유형(가정 기각/질문 재정의/무의미해짐)은 secondary management 계약의 명시적 action이 소유한다.
|
||||
*/
|
||||
private static final String DEFAULT_RESOLUTION_TYPE = "DECISION_MADE";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final StudioRelationStore relations;
|
||||
private final StudioJson json;
|
||||
private final Supplier<UUID> idGenerator;
|
||||
private final ProjectLinkStore projectLinks;
|
||||
|
||||
QuestionWorkingCopyStore(
|
||||
JdbcClient jdbcClient,
|
||||
StudioRelationStore relations,
|
||||
StudioJson json,
|
||||
Supplier<UUID> idGenerator,
|
||||
ProjectLinkStore projectLinks) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.relations = relations;
|
||||
this.json = json;
|
||||
this.idGenerator = idGenerator;
|
||||
this.projectLinks = projectLinks;
|
||||
}
|
||||
|
||||
Optional<WorkingCopyView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, version, updated_at, question, slug, summary, primary_topic_id,"
|
||||
+ " question_status, next_verification, options, resolution_summary,"
|
||||
+ " resolution_evidence_target_id, resolution_link_label"
|
||||
+ " FROM open_question WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID questionId = rs.getObject("id", UUID.class);
|
||||
WorkingCopyBaseInput base =
|
||||
new WorkingCopyBaseInput(
|
||||
RecordKind.QUESTION,
|
||||
rs.getString("question"),
|
||||
slugFromColumn(rs.getString("slug")),
|
||||
orEmpty(rs.getString("summary")),
|
||||
rs.getObject("primary_topic_id", UUID.class),
|
||||
projectLinks.findPrimaryProjectForQuestion(questionId).orElse(null),
|
||||
relations.findBySource(RecordKind.QUESTION, questionId));
|
||||
String domainStatus = rs.getString("question_status");
|
||||
return (WorkingCopyView)
|
||||
new WorkingCopyView.QuestionWorkingCopyView(
|
||||
questionId,
|
||||
rs.getLong("version"),
|
||||
instant(rs, "updated_at"),
|
||||
base,
|
||||
toContractStatus(domainStatus),
|
||||
points(questionId, "FACT"),
|
||||
points(questionId, "ASSUMPTION"),
|
||||
points(questionId, "UNKNOWN"),
|
||||
points(questionId, "CONSTRAINT"),
|
||||
json.optionsFromJson(rs.getString("options")),
|
||||
orEmpty(rs.getString("next_verification")),
|
||||
resolutionOf(
|
||||
domainStatus,
|
||||
rs.getString("resolution_summary"),
|
||||
rs.getObject("resolution_evidence_target_id", UUID.class),
|
||||
rs.getString("resolution_link_label")));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
UUID create(WorkingCopyInputView.QuestionInputView input, String principal) {
|
||||
UUID id = idGenerator.get();
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
boolean resolved = input.questionStatus() == QuestionStatusView.RESOLVED;
|
||||
requireUsableResolution(input, resolved);
|
||||
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO open_question (id, slug, question, summary, primary_topic_id,"
|
||||
+ " next_verification, options, question_status, resolution_type,"
|
||||
+ " resolution_summary, resolution_evidence_target_id, resolution_link_label,"
|
||||
+ " resolved_at, version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :slug, :question, :summary, :topicId, :nextValidation,"
|
||||
+ " CAST(:options AS jsonb), :status, :resolutionType, :resolutionSummary,"
|
||||
+ " :evidenceTargetId, :linkLabel,"
|
||||
// 해결 시각은 애플리케이션 시계가 아니라 DB 시계로 찍는다 — 같은 트랜잭션의
|
||||
// 다른 타임스탬프(created_at/updated_at)와 기준이 같아야 순서가 뒤집히지 않는다.
|
||||
+ " CASE WHEN :resolved THEN now() ELSE NULL END, 1, :principal, :principal)")
|
||||
.param("id", id)
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("question", orEmpty(base.title()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("topicId", base.topicId())
|
||||
.param("nextValidation", orEmpty(input.nextValidation()))
|
||||
.param("options", json.optionsToJson(input.options()))
|
||||
.param("status", resolved ? "RESOLVED" : "OPEN")
|
||||
.param("resolutionType", resolved ? DEFAULT_RESOLUTION_TYPE : null)
|
||||
.param("resolutionSummary", resolved ? input.resolution().summary() : null)
|
||||
.param("evidenceTargetId", resolved ? input.resolution().evidenceTargetId() : null)
|
||||
.param("linkLabel", resolved ? orEmpty(input.resolution().linkLabel()) : "")
|
||||
.param("resolved", resolved)
|
||||
.param("principal", principal)
|
||||
.update();
|
||||
|
||||
replacePoints(id, input);
|
||||
relations.replace(RecordKind.QUESTION, id, base.relations());
|
||||
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
|
||||
return id;
|
||||
}
|
||||
|
||||
boolean save(
|
||||
UUID id,
|
||||
long expectedVersion,
|
||||
WorkingCopyInputView.QuestionInputView input,
|
||||
String principal) {
|
||||
WorkingCopyBaseInput base = input.base();
|
||||
String storedStatus =
|
||||
jdbcClient
|
||||
.sql("SELECT question_status FROM open_question WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElse("OPEN");
|
||||
|
||||
boolean resolveNow =
|
||||
input.questionStatus() == QuestionStatusView.RESOLVED && !"RESOLVED".equals(storedStatus);
|
||||
requireUsableResolution(input, input.questionStatus() == QuestionStatusView.RESOLVED);
|
||||
|
||||
// ADR-003: 축약 상태가 Domain lifecycle 을 덮어쓰지 않는다. OPEN 계열 안에서의 값 변화는 무시하고,
|
||||
// 이미 RESOLVED 인 질문을 OPEN 으로 되돌리는 것도 저장이 할 일이 아니다 — reopen 은 secondary
|
||||
// management 계약의 명시적 action 이다.
|
||||
String nextStatus = resolveNow ? "RESOLVED" : storedStatus;
|
||||
boolean resolvedAfter = "RESOLVED".equals(nextStatus);
|
||||
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE open_question SET slug = :slug, question = :question, summary = :summary,"
|
||||
+ " primary_topic_id = :topicId, next_verification = :nextValidation,"
|
||||
+ " options = CAST(:options AS jsonb), question_status = :status,"
|
||||
+ " resolution_type = CASE WHEN :resolved THEN"
|
||||
+ " COALESCE(resolution_type, :resolutionType) ELSE resolution_type END,"
|
||||
+ " resolution_summary = CASE WHEN :resolved THEN :resolutionSummary"
|
||||
+ " ELSE resolution_summary END,"
|
||||
+ " resolution_evidence_target_id = CASE WHEN :resolved THEN :evidenceTargetId"
|
||||
+ " ELSE resolution_evidence_target_id END,"
|
||||
+ " resolution_link_label = CASE WHEN :resolved THEN :linkLabel"
|
||||
+ " ELSE resolution_link_label END,"
|
||||
+ " resolved_at = CASE WHEN :resolved THEN COALESCE(resolved_at, now())"
|
||||
+ " ELSE resolved_at END,"
|
||||
+ " version = version + 1, updated_at = now(), updated_by = :principal"
|
||||
+ " WHERE id = :id AND version = :expectedVersion")
|
||||
.param("slug", slugToColumn(base.slug()))
|
||||
.param("question", orEmpty(base.title()))
|
||||
.param("summary", orEmpty(base.summary()))
|
||||
.param("topicId", base.topicId())
|
||||
.param("nextValidation", orEmpty(input.nextValidation()))
|
||||
.param("options", json.optionsToJson(input.options()))
|
||||
.param("status", nextStatus)
|
||||
.param("resolved", resolvedAfter)
|
||||
.param("resolutionType", DEFAULT_RESOLUTION_TYPE)
|
||||
.param("resolutionSummary", resolvedAfter ? input.resolution().summary() : null)
|
||||
.param("evidenceTargetId", resolvedAfter ? input.resolution().evidenceTargetId() : null)
|
||||
.param("linkLabel", resolvedAfter ? orEmpty(input.resolution().linkLabel()) : "")
|
||||
.param("principal", principal)
|
||||
.param("id", id)
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.update();
|
||||
if (updated == 0) {
|
||||
return false;
|
||||
}
|
||||
replacePoints(id, input);
|
||||
relations.replace(RecordKind.QUESTION, id, base.relations());
|
||||
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code RESOLVED}는 {@code ck_question_resolution_consistency}가 요약을 요구한다. 요약 없이 해결 처리된 질문은 "왜
|
||||
* 끝났는지 모르는 종료"라 저장을 거부한다 — 여기서 거부하지 않으면 DB 제약 위반이 500으로 나가 클라이언트가 무엇이 잘못됐는지 알 수 없다.
|
||||
*/
|
||||
private static void requireUsableResolution(
|
||||
WorkingCopyInputView.QuestionInputView input, boolean resolved) {
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
QuestionResolutionView resolution = input.resolution();
|
||||
if (resolution == null || resolution.summary() == null || resolution.summary().isBlank()) {
|
||||
throw StudioException.of(
|
||||
StudioError.REQUEST_VALIDATION_FAILED,
|
||||
"document.resolution.summary is required when questionStatus is RESOLVED");
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestionStatusView toContractStatus(String domainStatus) {
|
||||
return "RESOLVED".equals(domainStatus) ? QuestionStatusView.RESOLVED : QuestionStatusView.OPEN;
|
||||
}
|
||||
|
||||
private static QuestionResolutionView resolutionOf(
|
||||
String domainStatus, String summary, UUID evidenceTargetId, String linkLabel) {
|
||||
if (!"RESOLVED".equals(domainStatus)) {
|
||||
return null;
|
||||
}
|
||||
return new QuestionResolutionView(orEmpty(summary), evidenceTargetId, orEmpty(linkLabel));
|
||||
}
|
||||
|
||||
private List<OrderedTextView> points(UUID questionId, String pointKind) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, content, display_order FROM question_point"
|
||||
+ " WHERE question_id = :id AND point_kind = :kind ORDER BY display_order")
|
||||
.param("id", questionId)
|
||||
.param("kind", pointKind)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new OrderedTextView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("content"),
|
||||
rs.getInt("display_order")))
|
||||
.list();
|
||||
}
|
||||
|
||||
private void replacePoints(UUID questionId, WorkingCopyInputView.QuestionInputView input) {
|
||||
// 이 질문이 지금 소유한 point id. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다 — 남의 질문 것을
|
||||
// 그대로 쓰면 PK 가 충돌하고, 매번 새로 부여하면 편집기의 줄 식별자가 저장마다 바뀐다.
|
||||
Set<UUID> owned =
|
||||
Set.copyOf(
|
||||
jdbcClient
|
||||
.sql("SELECT id FROM question_point WHERE question_id = :id")
|
||||
.param("id", questionId)
|
||||
.query(UUID.class)
|
||||
.list());
|
||||
jdbcClient
|
||||
.sql("DELETE FROM question_point WHERE question_id = :id")
|
||||
.param("id", questionId)
|
||||
.update();
|
||||
insertPoints(questionId, "FACT", input.facts(), owned);
|
||||
insertPoints(questionId, "ASSUMPTION", input.assumptions(), owned);
|
||||
insertPoints(questionId, "UNKNOWN", input.unknowns(), owned);
|
||||
insertPoints(questionId, "CONSTRAINT", input.constraints(), owned);
|
||||
}
|
||||
|
||||
private void insertPoints(
|
||||
UUID questionId, String pointKind, List<OrderedTextView> items, Set<UUID> owned) {
|
||||
int order = 0;
|
||||
for (OrderedTextView item : items) {
|
||||
// content 는 CHECK (length(trim(content)) > 0) 다. 빈 줄은 편집 중 흔한 상태이므로 거부하는 대신
|
||||
// 저장하지 않는다 — 계약도 OrderedText.text 에 minLength 1 을 두어 빈 항목을 보내지 말라고 한다.
|
||||
if (item.text() == null || item.text().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
|
||||
+ " VALUES (:id, :questionId, :kind, :content, :order)")
|
||||
.param(
|
||||
"id",
|
||||
(item.id() != null && owned.contains(item.id())) ? item.id() : idGenerator.get())
|
||||
.param("questionId", questionId)
|
||||
.param("kind", pointKind)
|
||||
.param("content", item.text())
|
||||
.param("order", order++)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
|
||||
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* 설계 스키마가 jsonb 배열로 정한 순서 있는 항목들을 읽고 쓴다.
|
||||
*
|
||||
* <p>Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 이 값들은 <b>DB에 영속되는 형태</b>라 application record의 필드 이름이
|
||||
* 바뀌면 이미 저장된 행을 읽지 못하게 된다 — 그 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다.
|
||||
*/
|
||||
final class StudioJson {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
StudioJson(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
String orderedTextToJson(List<OrderedTextView> items) {
|
||||
ArrayNode array = mapper.createArrayNode();
|
||||
for (OrderedTextView item : items) {
|
||||
ObjectNode node = array.addObject();
|
||||
node.put("id", item.id() == null ? null : item.id().toString());
|
||||
node.put("text", item.text());
|
||||
node.put("order", item.order());
|
||||
}
|
||||
return write(array);
|
||||
}
|
||||
|
||||
List<OrderedTextView> orderedTextFromJson(String json) {
|
||||
return read(json)
|
||||
.valueStream()
|
||||
.map(
|
||||
node ->
|
||||
new OrderedTextView(
|
||||
uuid(node, "id"), text(node, "text"), node.path("order").asInt(0)))
|
||||
.sorted(java.util.Comparator.comparingInt(OrderedTextView::order))
|
||||
.toList();
|
||||
}
|
||||
|
||||
String rulesToJson(List<ReferenceRuleView> items) {
|
||||
ArrayNode array = mapper.createArrayNode();
|
||||
for (ReferenceRuleView item : items) {
|
||||
ObjectNode node = array.addObject();
|
||||
node.put("id", item.id() == null ? null : item.id().toString());
|
||||
node.put("title", item.title());
|
||||
node.put("body", item.body());
|
||||
node.put("order", item.order());
|
||||
}
|
||||
return write(array);
|
||||
}
|
||||
|
||||
List<ReferenceRuleView> rulesFromJson(String json) {
|
||||
return read(json)
|
||||
.valueStream()
|
||||
.map(
|
||||
node ->
|
||||
new ReferenceRuleView(
|
||||
uuid(node, "id"),
|
||||
text(node, "title"),
|
||||
text(node, "body"),
|
||||
node.path("order").asInt(0)))
|
||||
.sorted(java.util.Comparator.comparingInt(ReferenceRuleView::order))
|
||||
.toList();
|
||||
}
|
||||
|
||||
String optionsToJson(List<QuestionOptionView> items) {
|
||||
ArrayNode array = mapper.createArrayNode();
|
||||
for (QuestionOptionView item : items) {
|
||||
ObjectNode node = array.addObject();
|
||||
node.put("id", item.id() == null ? null : item.id().toString());
|
||||
node.put("title", item.title());
|
||||
node.put("description", item.description());
|
||||
node.put("order", item.order());
|
||||
}
|
||||
return write(array);
|
||||
}
|
||||
|
||||
List<QuestionOptionView> optionsFromJson(String json) {
|
||||
return read(json)
|
||||
.valueStream()
|
||||
.map(
|
||||
node ->
|
||||
new QuestionOptionView(
|
||||
uuid(node, "id"),
|
||||
text(node, "title"),
|
||||
text(node, "description"),
|
||||
node.path("order").asInt(0)))
|
||||
.sorted(java.util.Comparator.comparingInt(QuestionOptionView::order))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String write(ArrayNode array) {
|
||||
try {
|
||||
return mapper.writeValueAsString(array);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to serialise a Studio jsonb column", e);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode read(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return mapper.createArrayNode();
|
||||
}
|
||||
try {
|
||||
JsonNode node = mapper.readTree(json);
|
||||
return node.isArray() ? node : mapper.createArrayNode();
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to read a Studio jsonb column", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID uuid(JsonNode node, String field) {
|
||||
String value = node.path(field).asString(null);
|
||||
return (value == null || value.isBlank()) ? null : UUID.fromString(value);
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.path(field).asString("");
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* 네 유형이 공유하는 편집용 관계({@code studio_relation})를 읽고 통째로 교체한다.
|
||||
*
|
||||
* <p>부분 갱신이 아니라 교체인 이유: 계약의 {@code relations}는 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 곧 최종 상태다. 지운 줄을 알아내려고
|
||||
* diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다.
|
||||
*/
|
||||
final class StudioRelationStore {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final Supplier<UUID> idGenerator;
|
||||
|
||||
StudioRelationStore(JdbcClient jdbcClient, Supplier<UUID> idGenerator) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
List<RelationView> findBySource(RecordKind kind, UUID sourceId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, target_id, reason, display_order FROM studio_relation "
|
||||
+ "WHERE source_kind = :kind AND source_id = :sourceId ORDER BY display_order")
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", sourceId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new RelationView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("target_id", UUID.class),
|
||||
rs.getString("reason"),
|
||||
rs.getInt("display_order")))
|
||||
.list();
|
||||
}
|
||||
|
||||
void replace(RecordKind kind, UUID sourceId, List<RelationView> relations) {
|
||||
// 이 source 가 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다.
|
||||
Set<UUID> owned =
|
||||
Set.copyOf(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT id FROM studio_relation "
|
||||
+ "WHERE source_kind = :kind AND source_id = :sourceId")
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", sourceId)
|
||||
.query(UUID.class)
|
||||
.list());
|
||||
|
||||
jdbcClient
|
||||
.sql("DELETE FROM studio_relation WHERE source_kind = :kind AND source_id = :sourceId")
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", sourceId)
|
||||
.update();
|
||||
|
||||
for (RelationView relation : relations) {
|
||||
// 이 source 가 원래 갖고 있던 id 는 그대로 둔다 — 편집기가 줄을 식별하는 키라 매 저장마다 바뀌면
|
||||
// 화면의 줄이 통째로 갈아엎어진 것처럼 보인다. 그 밖의 id(남의 문서 것이거나 클라이언트가 지어낸
|
||||
// 것)는 신뢰하지 않고 새로 부여한다 — 그대로 쓰면 다른 문서의 관계 행과 PK 가 충돌한다.
|
||||
UUID id =
|
||||
(relation.id() != null && owned.contains(relation.id()))
|
||||
? relation.id()
|
||||
: idGenerator.get();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO studio_relation "
|
||||
+ "(id, source_kind, source_id, target_id, reason, display_order) "
|
||||
+ "VALUES (:id, :kind, :sourceId, :targetId, :reason, :order)")
|
||||
.param("id", id)
|
||||
.param("kind", kind.name())
|
||||
.param("sourceId", sourceId)
|
||||
.param("targetId", relation.targetId())
|
||||
.param("reason", relation.reason() == null ? "" : relation.reason())
|
||||
.param("order", relation.order())
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
void deleteBySource(RecordKind kind, UUID sourceId) {
|
||||
replace(kind, sourceId, List.of());
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** 계약 타입과 컬럼 타입 사이의 되풀이되는 변환. */
|
||||
final class StudioSqlSupport {
|
||||
|
||||
private StudioSqlSupport() {}
|
||||
|
||||
/**
|
||||
* 계약의 빈 slug("아직 정하지 않음")를 NULL로 옮긴다. 빈 문자열을 그대로 넣으면 {@code ck_document_slug_non_blank} 계열 제약에
|
||||
* 걸리고, 무엇보다 여러 초안이 같은 빈 slug를 갖는 순간 slug UNIQUE 제약이 두 번째 초안을 거부한다.
|
||||
*/
|
||||
static String slugToColumn(String slug) {
|
||||
return (slug == null || slug.isBlank()) ? null : slug;
|
||||
}
|
||||
|
||||
/** 반대 방향. 계약은 slug가 null이 아니라 빈 문자열이어야 한다고 정한다. */
|
||||
static String slugFromColumn(String slug) {
|
||||
return slug == null ? "" : slug;
|
||||
}
|
||||
|
||||
static String orEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약의 {@code format: date}를 {@code timestamptz} 컬럼에 담는다. 자정 UTC로 고정한다 — 저장 시각의 로컬 타임존을 쓰면 같은 날짜가
|
||||
* 서버 위치에 따라 다른 순간이 되고, 다시 읽을 때 하루가 밀린다.
|
||||
*/
|
||||
static Timestamp dateToTimestamp(LocalDate date) {
|
||||
return date == null ? null : Timestamp.from(date.atStartOfDay(ZoneOffset.UTC).toInstant());
|
||||
}
|
||||
|
||||
static LocalDate dateFromTimestamp(ResultSet rs, String column) throws SQLException {
|
||||
Timestamp value = rs.getTimestamp(column);
|
||||
return value == null ? null : value.toInstant().atZone(ZoneOffset.UTC).toLocalDate();
|
||||
}
|
||||
|
||||
static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||
Timestamp value = rs.getTimestamp(column);
|
||||
return value == null ? null : value.toInstant();
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
-- Studio WorkingCopy 계약(studio-v1.yaml)이 요구하는 편집 필드를 네 source aggregate에 채운다.
|
||||
--
|
||||
-- 배경: V7(= 설계 패키지 database/V1__init.sql)의 물리 스키마는 유형마다 다른 모델을 갖는데,
|
||||
-- Studio 계약은 네 유형을 공통 base(WorkingCopyInputBase) + 유형별 확장이라는 하나의 편집
|
||||
-- 흐름으로 다룬다. 그 공통 base와 유형별 필드 중 V7에 대응 컬럼이 없는 것만 여기서 채운다.
|
||||
-- 계약에 없는 V7 컬럼(environment_items, alternatives_markdown, freshness_status 등)은
|
||||
-- 건드리지 않는다 — 설계 패키지의 정본 스키마이고 public-v1 구현이 쓸 수 있다.
|
||||
--
|
||||
-- ADR-003과 충돌하지 않는다. ADR-003이 금지한 것은 네 Aggregate를 하나의 범용
|
||||
-- `working_copy` 테이블 + 통짜 JSON으로 뭉개는 것이다. 여기서는 각 Aggregate가 자기
|
||||
-- 테이블을 그대로 소유한 채 필요한 컬럼만 얻는다.
|
||||
|
||||
-- ── 공통 base ───────────────────────────────────────────────────────────────
|
||||
-- 계약 WorkingCopyInputBase.summary (maxLength 300). open_question.summary(varchar 600)과
|
||||
-- project_decision(아래)에는 각각 대응 컬럼이 있거나 새로 만든다.
|
||||
ALTER TABLE document ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
|
||||
|
||||
-- ── CASE ────────────────────────────────────────────────────────────────────
|
||||
-- 계약의 problem/conclusion은 maxLength 100000인 본문이다. V7의 *_summary는 varchar(600)이라
|
||||
-- 그대로 쓰면 잘린다 — text로 넓힌다(폭을 늘리는 변경이라 기존 행에 무손실).
|
||||
ALTER TABLE case_detail ALTER COLUMN problem_summary TYPE text;
|
||||
ALTER TABLE case_detail ALTER COLUMN conclusion_summary TYPE text;
|
||||
-- environment_items(jsonb 배열)는 계약의 environment(단일 문자열)와 다른 모양이다.
|
||||
-- 계약 쪽을 담을 컬럼을 따로 둔다. 기존 컬럼은 그대로 남긴다.
|
||||
ALTER TABLE case_detail ADD COLUMN environment text NOT NULL DEFAULT '';
|
||||
ALTER TABLE case_detail ADD COLUMN reproduction text NOT NULL DEFAULT '';
|
||||
|
||||
-- ── REFERENCE ───────────────────────────────────────────────────────────────
|
||||
ALTER TABLE reference_detail ALTER COLUMN scope_summary TYPE text;
|
||||
-- rules[] = ReferenceRule{id,title,body,order}, examples[] = OrderedText{id,text,order}.
|
||||
-- applyWhen[]/exceptions[]는 기존 applies_to/excluded_scope를 그대로 쓴다.
|
||||
ALTER TABLE reference_detail ADD COLUMN rules jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(rules) = 'array');
|
||||
ALTER TABLE reference_detail ADD COLUMN examples jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(examples) = 'array');
|
||||
|
||||
-- ── QUESTION ────────────────────────────────────────────────────────────────
|
||||
-- options[] = QuestionOption{id,title,description,order}.
|
||||
-- facts/assumptions/unknowns/constraints는 기존 question_point(point_kind)로 간다.
|
||||
ALTER TABLE open_question ADD COLUMN options jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(options) = 'array');
|
||||
-- QuestionResolution{summary, evidenceTargetId, linkLabel} 중 summary만 V7에 있다.
|
||||
ALTER TABLE open_question ADD COLUMN resolution_evidence_target_id uuid;
|
||||
ALTER TABLE open_question ADD COLUMN resolution_link_label varchar(120) NOT NULL DEFAULT '';
|
||||
|
||||
-- ── PROJECT_DECISION ────────────────────────────────────────────────────────
|
||||
-- 계약은 PROJECT_DECISION도 다른 셋과 같은 공통 base(title/slug/summary/topicId)로 편집한다.
|
||||
-- V7의 project_decision에는 statement/rationale/consequences만 있다.
|
||||
ALTER TABLE project_decision ADD COLUMN title varchar(120) NOT NULL DEFAULT '';
|
||||
ALTER TABLE project_decision ADD COLUMN slug varchar(180);
|
||||
ALTER TABLE project_decision ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
|
||||
ALTER TABLE project_decision ADD COLUMN primary_topic_id uuid REFERENCES topic(id);
|
||||
-- 계약 statement는 maxLength 100000이다. varchar(1000)이면 잘린다.
|
||||
ALTER TABLE project_decision ALTER COLUMN statement TYPE text;
|
||||
-- 계약은 PROJECT_DECISION 의 projectId 를 "게시 시점에 non-null, 저장 시점에는 강제하지 않음"으로
|
||||
-- 정한다(studio-v1.yaml WorkingCopyInputBase.projectId). V7 의 NOT NULL 은 그 초안 저장을
|
||||
-- 구조적으로 불가능하게 만든다 — 게시 필수 여부는 검증이 판단하도록 컬럼 제약을 푼다.
|
||||
ALTER TABLE project_decision ALTER COLUMN project_id DROP NOT NULL;
|
||||
ALTER TABLE project_decision ADD CONSTRAINT uq_project_decision_slug UNIQUE (slug);
|
||||
ALTER TABLE project_decision ADD CONSTRAINT ck_project_decision_slug_non_blank
|
||||
CHECK (slug IS NULL OR length(trim(slug)) > 0);
|
||||
|
||||
-- ── relations ───────────────────────────────────────────────────────────────
|
||||
-- 계약의 relations[]는 네 유형 공통 base에 있고 항목이 {id, targetId, reason, order}다.
|
||||
-- V7의 document_relation은 (source, target, relation_type) 복합 PK라 항목 자체의 id도
|
||||
-- reason도 없고 document끼리만 성립한다 — QUESTION/PROJECT_DECISION의 relations를 담을 수
|
||||
-- 없다. 그래서 Studio 편집용 관계는 전용 테이블에 둔다. document_relation은 설계의 공개
|
||||
-- 렌더링용 유형 관계로 그대로 남는다.
|
||||
--
|
||||
-- (source_kind, source_id) 다형 참조는 이 스키마가 studio_validation/studio_preview에서
|
||||
-- 이미 쓰는 방식과 같다. 다형 참조라 FK를 걸 수 없으므로 부모 삭제 시 정리는 application이
|
||||
-- 책임진다.
|
||||
--
|
||||
-- target_id가 nullable인 것은 계약(RelationInput.targetId: [string,"null"])을 따른 것이다 —
|
||||
-- 아직 대상을 고르지 않은 관계 줄도 저장할 수 있어야 한다. 게시 가능 여부는 검증이 판단한다.
|
||||
CREATE TABLE studio_relation (
|
||||
id uuid PRIMARY KEY,
|
||||
source_kind varchar(30) NOT NULL
|
||||
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
|
||||
source_id uuid NOT NULL,
|
||||
target_id uuid,
|
||||
reason text NOT NULL DEFAULT '',
|
||||
display_order integer NOT NULL CHECK (display_order >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_studio_relation_order UNIQUE (source_kind, source_id, display_order),
|
||||
CONSTRAINT ck_studio_relation_not_self CHECK (target_id IS NULL OR target_id <> source_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_studio_relation_source ON studio_relation (source_kind, source_id);
|
||||
CREATE INDEX idx_studio_relation_target ON studio_relation (target_id)
|
||||
WHERE target_id IS NOT NULL;
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.studio;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcPreviewArtifactAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcValidationArtifactAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcDependencyRevisionAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDashboardQueryAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDependencyResolverAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDocumentQueryAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset.JdbcAssetRepositoryAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationHistoryQueryAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationWriterAdapter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.JdbcWorkingCopyRepositoryAdapter;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.AssetView;
|
||||
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
|
||||
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
|
||||
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
|
||||
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
|
||||
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
|
||||
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
|
||||
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Studio 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
|
||||
*
|
||||
* <p>이 테스트가 필요한 이유는 분명하다 — 이 저장소의 표준 {@code check} 는 Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이
|
||||
* 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 의 지연 검사도 검증하지
|
||||
* 못한다.
|
||||
*
|
||||
* <p>{@code JdbcCatalogQueryAdapterTest} 와 같은 형제 패턴을 쓴다 — 이 모듈에는 {@code @SpringBootConfiguration} 이
|
||||
* 없으므로 Testcontainers + 순수 Flyway + 직접 조립이다.
|
||||
*/
|
||||
class StudioPersistenceIntegrationTest {
|
||||
|
||||
private static final String IMAGE =
|
||||
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
|
||||
|
||||
private static PostgreSQLContainer postgres;
|
||||
private static HikariDataSource dataSource;
|
||||
private static JdbcClient jdbcClient;
|
||||
|
||||
private static JdbcWorkingCopyRepositoryAdapter workingCopies;
|
||||
private static JdbcStudioDocumentQueryAdapter documents;
|
||||
private static JdbcStudioDependencyResolverAdapter dependencies;
|
||||
private static JdbcDependencyRevisionAdapter dependencyRevisions;
|
||||
private static JdbcValidationArtifactAdapter validations;
|
||||
private static JdbcPreviewArtifactAdapter previews;
|
||||
private static JdbcPublicationWriterAdapter publicationWriter;
|
||||
private static JdbcPublicationHistoryQueryAdapter publicationHistory;
|
||||
private static JdbcStudioDashboardQueryAdapter dashboard;
|
||||
private static JdbcAssetRepositoryAdapter assets;
|
||||
private static org.springframework.transaction.support.TransactionTemplate transactions;
|
||||
|
||||
private static UUID topicId;
|
||||
private static UUID projectId;
|
||||
|
||||
@BeforeAll
|
||||
static void migrateFreshDatabase() {
|
||||
if (!DockerClientFactory.instance().isDockerAvailable()) {
|
||||
throw new IllegalStateException(
|
||||
"Docker is required for the Studio 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();
|
||||
|
||||
workingCopies = new JdbcWorkingCopyRepositoryAdapter(jdbcClient, objectMapper);
|
||||
documents = new JdbcStudioDocumentQueryAdapter(jdbcClient);
|
||||
dependencies = new JdbcStudioDependencyResolverAdapter(jdbcClient);
|
||||
dependencyRevisions = new JdbcDependencyRevisionAdapter(jdbcClient);
|
||||
validations = new JdbcValidationArtifactAdapter(jdbcClient, objectMapper);
|
||||
previews = new JdbcPreviewArtifactAdapter(jdbcClient);
|
||||
publicationWriter = new JdbcPublicationWriterAdapter(jdbcClient, objectMapper);
|
||||
publicationHistory = new JdbcPublicationHistoryQueryAdapter(jdbcClient);
|
||||
dashboard = new JdbcStudioDashboardQueryAdapter(jdbcClient);
|
||||
assets = new JdbcAssetRepositoryAdapter(jdbcClient);
|
||||
// 게시는 프로덕션에서 TransactionPort.inWrite 안에서 돈다. 순환 FK 의 지연 검사가 성립하려면
|
||||
// 트랜잭션이 반드시 있어야 하므로 테스트도 같은 조건에서 호출한다.
|
||||
transactions =
|
||||
new org.springframework.transaction.support.TransactionTemplate(
|
||||
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
|
||||
|
||||
topicId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'Kafka', 'kafka', 'kafka', 'test', 'test')")
|
||||
.param("id", topicId)
|
||||
.update();
|
||||
|
||||
projectId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, slug, name, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'tech-log', 'Tech Log', 'test', 'test')")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
if (postgres != null) {
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkingCopyBaseInput base(RecordKind kind, String title, String slug) {
|
||||
return new WorkingCopyBaseInput(kind, title, slug, "요약", topicId, projectId, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v8AddsEveryColumnTheStudioContractNeeds() {
|
||||
List<String> caseColumns = columnsOf("case_detail");
|
||||
assertThat(caseColumns).contains("environment", "reproduction");
|
||||
|
||||
assertThat(columnsOf("reference_detail")).contains("rules", "examples");
|
||||
assertThat(columnsOf("open_question"))
|
||||
.contains("options", "resolution_evidence_target_id", "resolution_link_label");
|
||||
assertThat(columnsOf("project_decision"))
|
||||
.contains("title", "slug", "summary", "primary_topic_id");
|
||||
assertThat(columnsOf("document")).contains("summary");
|
||||
assertThat(columnsOf("studio_relation")).contains("source_kind", "source_id", "target_id");
|
||||
|
||||
// 계약이 허용한 "프로젝트 없는 결정 초안" 저장이 가능해야 한다.
|
||||
assertThat(isNullable("project_decision", "project_id")).isTrue();
|
||||
// 계약의 본문 길이는 100000 이라 varchar(600)/varchar(1000) 로는 담을 수 없다.
|
||||
assertThat(typeOf("case_detail", "problem_summary")).isEqualTo("text");
|
||||
assertThat(typeOf("project_decision", "statement")).isEqualTo("text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCaseWorkingCopyRoundTripsThroughEveryColumnItTouches() {
|
||||
WorkingCopyView created =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "장애 사례", "outage-case"),
|
||||
"문제",
|
||||
"결론",
|
||||
"환경",
|
||||
"재현",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
"본문 :::evidence key=\"x\""),
|
||||
"tester");
|
||||
|
||||
assertThat(created.version()).as("계약의 version 은 minimum 1 이다").isEqualTo(1L);
|
||||
|
||||
WorkingCopyView loaded = workingCopies.find(created.id()).orElseThrow();
|
||||
assertThat(loaded).isInstanceOf(WorkingCopyView.CaseWorkingCopyView.class);
|
||||
WorkingCopyView.CaseWorkingCopyView value = (WorkingCopyView.CaseWorkingCopyView) loaded;
|
||||
assertThat(value.problem()).isEqualTo("문제");
|
||||
assertThat(value.reproduction()).isEqualTo("재현");
|
||||
assertThat(value.lastVerifiedOn()).isEqualTo(LocalDate.of(2026, 8, 1));
|
||||
assertThat(value.base().topicId()).isEqualTo(topicId);
|
||||
assertThat(value.base().projectId()).as("프로젝트 링크 테이블 왕복").isEqualTo(projectId);
|
||||
|
||||
assertThat(workingCopies.findKind(created.id())).contains(RecordKind.CASE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingWithTheWrongVersionChangesNothing() {
|
||||
WorkingCopyView created =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "낙관적 잠금", "optimistic-lock"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
|
||||
Optional<WorkingCopyView> conflict =
|
||||
workingCopies.save(
|
||||
created.id(),
|
||||
created.version() + 99,
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "덮어쓰기 시도", "optimistic-lock"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
assertThat(conflict).isEmpty();
|
||||
assertThat(workingCopies.find(created.id()).orElseThrow().base().title()).isEqualTo("낙관적 잠금");
|
||||
|
||||
WorkingCopyView saved =
|
||||
workingCopies
|
||||
.save(
|
||||
created.id(),
|
||||
created.version(),
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "정상 저장", "optimistic-lock"), "", "", "", "", null, ""),
|
||||
"tester")
|
||||
.orElseThrow();
|
||||
assertThat(saved.version()).isEqualTo(created.version() + 1);
|
||||
assertThat(saved.base().title()).isEqualTo("정상 저장");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOtherThreeKindsRoundTripThroughTheirOwnTables() {
|
||||
WorkingCopyView reference =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.ReferenceInputView(
|
||||
base(RecordKind.REFERENCE, "기준 문서", "reference-doc"),
|
||||
"목적",
|
||||
List.of(new ReferenceRuleView(UUID.randomUUID(), "규칙", "본문", 0)),
|
||||
List.of(new OrderedTextView(UUID.randomUUID(), "적용", 0)),
|
||||
List.of(),
|
||||
List.of(new OrderedTextView(UUID.randomUUID(), "예시", 0)),
|
||||
LocalDate.of(2026, 7, 1)),
|
||||
"tester");
|
||||
WorkingCopyView.ReferenceWorkingCopyView loadedReference =
|
||||
(WorkingCopyView.ReferenceWorkingCopyView) workingCopies.find(reference.id()).orElseThrow();
|
||||
assertThat(loadedReference.rules())
|
||||
.singleElement()
|
||||
.extracting(ReferenceRuleView::title)
|
||||
.isEqualTo("규칙");
|
||||
assertThat(loadedReference.examples())
|
||||
.singleElement()
|
||||
.extracting(OrderedTextView::text)
|
||||
.isEqualTo("예시");
|
||||
|
||||
WorkingCopyView question =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.QuestionInputView(
|
||||
base(RecordKind.QUESTION, "미해결 질문", "open-question"),
|
||||
QuestionStatusView.OPEN,
|
||||
List.of(new OrderedTextView(UUID.randomUUID(), "사실", 0)),
|
||||
List.of(),
|
||||
List.of(new OrderedTextView(UUID.randomUUID(), "모르는 것", 0)),
|
||||
List.of(),
|
||||
List.of(),
|
||||
"다음 검증",
|
||||
null),
|
||||
"tester");
|
||||
WorkingCopyView.QuestionWorkingCopyView loadedQuestion =
|
||||
(WorkingCopyView.QuestionWorkingCopyView) workingCopies.find(question.id()).orElseThrow();
|
||||
assertThat(loadedQuestion.facts())
|
||||
.singleElement()
|
||||
.extracting(OrderedTextView::text)
|
||||
.isEqualTo("사실");
|
||||
assertThat(loadedQuestion.unknowns()).hasSize(1);
|
||||
assertThat(loadedQuestion.nextValidation()).isEqualTo("다음 검증");
|
||||
assertThat(loadedQuestion.base().projectId()).isEqualTo(projectId);
|
||||
|
||||
WorkingCopyView decision =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.ProjectDecisionInputView(
|
||||
base(RecordKind.PROJECT_DECISION, "결정", "a-decision"),
|
||||
DecisionStatusView.ADOPTED,
|
||||
LocalDate.of(2026, 6, 1),
|
||||
"결정문",
|
||||
"근거",
|
||||
List.of(new OrderedTextView(UUID.randomUUID(), "결과", 0))),
|
||||
"tester");
|
||||
WorkingCopyView.ProjectDecisionWorkingCopyView loadedDecision =
|
||||
(WorkingCopyView.ProjectDecisionWorkingCopyView)
|
||||
workingCopies.find(decision.id()).orElseThrow();
|
||||
// 계약의 ADOPTED 는 Domain 의 ACCEPTED 다(ADR-003).
|
||||
assertThat(loadedDecision.decisionStatus()).isEqualTo(DecisionStatusView.ADOPTED);
|
||||
assertThat(storedDecisionStatus(decision.id())).isEqualTo("ACCEPTED");
|
||||
assertThat(loadedDecision.consequences()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void relationsSurviveAReplaceAndKeepTheirIdentity() {
|
||||
WorkingCopyView target =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "관계 대상", "relation-target"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
|
||||
RelationView relation = new RelationView(null, target.id(), "왜 관련 있는지", 0);
|
||||
WorkingCopyView source =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
new WorkingCopyBaseInput(
|
||||
RecordKind.CASE,
|
||||
"관계 원본",
|
||||
"relation-source",
|
||||
"요약",
|
||||
topicId,
|
||||
projectId,
|
||||
List.of(relation)),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
""),
|
||||
"tester");
|
||||
|
||||
List<RelationView> stored = workingCopies.find(source.id()).orElseThrow().base().relations();
|
||||
assertThat(stored).singleElement().extracting(RelationView::reason).isEqualTo("왜 관련 있는지");
|
||||
UUID relationId = stored.getFirst().id();
|
||||
assertThat(relationId).isNotNull();
|
||||
|
||||
WorkingCopyView saved =
|
||||
workingCopies
|
||||
.save(
|
||||
source.id(),
|
||||
source.version(),
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
new WorkingCopyBaseInput(
|
||||
RecordKind.CASE,
|
||||
"관계 원본",
|
||||
"relation-source",
|
||||
"요약",
|
||||
topicId,
|
||||
projectId,
|
||||
List.of(new RelationView(relationId, target.id(), "이유 수정", 0))),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
""),
|
||||
"tester")
|
||||
.orElseThrow();
|
||||
List<RelationView> afterSave = saved.base().relations();
|
||||
assertThat(afterSave).singleElement().extracting(RelationView::reason).isEqualTo("이유 수정");
|
||||
assertThat(afterSave.getFirst().id()).as("이 문서가 소유하던 관계 id 는 저장해도 유지된다").isEqualTo(relationId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theUnionQueryComputesDependencyRevisionAndNextAction() {
|
||||
WorkingCopyView document =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "목록 대상", "list-target"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
|
||||
var page =
|
||||
documents.list(
|
||||
new ListDocumentsQuery(
|
||||
"목록", RecordKind.CASE, null, null, projectId, DocumentSort.UPDATED_DESC, null, 20));
|
||||
assertThat(page.items()).extracting(item -> item.id()).contains(document.id());
|
||||
assertThat(page.items().getFirst().nextAction())
|
||||
.as("검증한 적이 없으면 다음 행동은 VALIDATE 다")
|
||||
.isEqualTo(NextAction.VALIDATE);
|
||||
|
||||
String revision = dependencyRevisions.revisionFor(RecordKind.CASE, document.id());
|
||||
assertThat(revision).isNotBlank();
|
||||
assertThat(dependencyRevisions.revisionFor(RecordKind.CASE, document.id()))
|
||||
.as("같은 상태면 같은 값이어야 한다")
|
||||
.isEqualTo(revision);
|
||||
|
||||
// 제목이 비면 검증할 의미가 없으므로 CONTINUE_EDITING 이다.
|
||||
workingCopies.save(
|
||||
document.id(),
|
||||
document.version(),
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "", "list-target"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
var blankTitlePage =
|
||||
documents.list(
|
||||
new ListDocumentsQuery(
|
||||
null,
|
||||
RecordKind.CASE,
|
||||
null,
|
||||
NextAction.CONTINUE_EDITING,
|
||||
projectId,
|
||||
DocumentSort.UPDATED_DESC,
|
||||
null,
|
||||
20));
|
||||
assertThat(blankTitlePage.items()).extracting(item -> item.id()).contains(document.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyResolutionFindsTopicProjectRelationsAndSlugOwners() {
|
||||
WorkingCopyView first =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "슬러그 주인", "shared-slug"), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
WorkingCopyView second =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "슬러그 경쟁", ""), "", "", "", "", null, ""),
|
||||
"tester");
|
||||
|
||||
StudioDependencyResolverPort.Resolved resolved =
|
||||
dependencies.resolve(workingCopies.find(first.id()).orElseThrow(), Set.of());
|
||||
assertThat(resolved.topic()).isNotNull();
|
||||
assertThat(resolved.topic().publicPath()).isEqualTo("/topics/kafka");
|
||||
assertThat(resolved.project().publicPath()).isEqualTo("/projects/tech-log");
|
||||
assertThat(resolved.publicPath()).isEqualTo("/cases/shared-slug");
|
||||
assertThat(resolved.topicMissing()).isFalse();
|
||||
assertThat(resolved.slugOwnerId()).as("자기 자신은 slug 충돌이 아니다").isNull();
|
||||
|
||||
// 빈 slug 는 NULL 로 저장되므로 UNIQUE 제약을 여러 초안이 함께 지날 수 있다.
|
||||
assertThat(workingCopies.find(second.id()).orElseThrow().base().slug()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationAndPreviewArtifactsPersistAndReadBack() {
|
||||
WorkingCopyView document =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "artifact", "artifact-case"), "문제", "결론", "", "", null, ""),
|
||||
"tester");
|
||||
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
ValidationReportView report =
|
||||
new ValidationReportView(
|
||||
UUID.randomUUID(),
|
||||
document.id(),
|
||||
document.version(),
|
||||
ValidationStatus.WARNINGS,
|
||||
List.of(
|
||||
new dev.caskeleton.application.techlog.studio.model.ValidationIssueView(
|
||||
"BODY_EMPTY",
|
||||
dev.caskeleton.application.techlog.studio.model.ValidationSeverity.WARNING,
|
||||
"/bodyMarkdown",
|
||||
"본문이 비어 있다")),
|
||||
now,
|
||||
now.plusSeconds(3600),
|
||||
"rev-1");
|
||||
validations.save(RecordKind.CASE, report, "tester");
|
||||
|
||||
ValidationReportView loaded = validations.findById(report.validationId()).orElseThrow();
|
||||
assertThat(loaded.status()).isEqualTo(ValidationStatus.WARNINGS);
|
||||
assertThat(loaded.issues())
|
||||
.singleElement()
|
||||
.extracting(issue -> issue.code())
|
||||
.isEqualTo("BODY_EMPTY");
|
||||
assertThat(validations.latestFor(RecordKind.CASE, document.id())).isPresent();
|
||||
|
||||
PublicPreviewView preview =
|
||||
new PublicPreviewView(
|
||||
UUID.randomUUID(),
|
||||
document.id(),
|
||||
document.version(),
|
||||
report.validationId(),
|
||||
"rev-1",
|
||||
now,
|
||||
now.plusSeconds(3600),
|
||||
"{\"kind\":\"CASE\"}");
|
||||
previews.save(RecordKind.CASE, preview, "tester");
|
||||
// jsonb 컬럼은 PostgreSQL 이 정규화해 되돌려준다(공백·키 순서가 입력과 같지 않다). 계약 DTO 로
|
||||
// 다시 파싱해 쓰는 값이라 의미는 보존되지만, 바이트 동일성을 기대하면 안 된다.
|
||||
String storedRenderModel =
|
||||
previews.findById(preview.previewId()).orElseThrow().renderModelJson();
|
||||
assertThat(new ObjectMapper().readTree(storedRenderModel).path("kind").asString(""))
|
||||
.isEqualTo("CASE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWritesTheWholeAggregateAndUnpublishWithdrawsIt() {
|
||||
WorkingCopyView document =
|
||||
workingCopies.create(
|
||||
new WorkingCopyInputView.CaseInputView(
|
||||
base(RecordKind.CASE, "게시 대상", "publish-target"), "문제", "결론", "", "", null, "본문"),
|
||||
"tester");
|
||||
|
||||
PublishResultView published =
|
||||
transactions.execute(
|
||||
status ->
|
||||
publicationWriter.publish(
|
||||
new PublicationWriterPort.PublishRequest(
|
||||
RecordKind.CASE,
|
||||
document.id(),
|
||||
document.version(),
|
||||
"게시 대상",
|
||||
"요약",
|
||||
topicId,
|
||||
projectId,
|
||||
"/cases/publish-target",
|
||||
null,
|
||||
"{\"kind\":\"CASE\"}",
|
||||
"본문 평문",
|
||||
List.of(),
|
||||
"1",
|
||||
"1",
|
||||
"idem-1",
|
||||
"tester")));
|
||||
|
||||
assertThat(published.publication().status()).isEqualTo(PublicationAggregateStatus.PUBLISHED);
|
||||
assertThat(published.publication().publicationRevision())
|
||||
.as("첫 게시의 revision 은 1 이다 — INSERT 가 넣은 값을 UPDATE 가 또 올리면 안 된다")
|
||||
.isEqualTo(1L);
|
||||
assertThat(published.event().snapshotAvailable()).isTrue();
|
||||
|
||||
// 순환 FK(publication.latest_event_id <-> publication_event)가 지연 검사로 통과해야 한다.
|
||||
assertThat(published.publication().latestEventId())
|
||||
.isEqualTo(published.event().publicationEventId());
|
||||
|
||||
assertThat(count("public_resource_projection", "resource_id", document.id())).isEqualTo(1);
|
||||
assertThat(count("public_route", "resource_id", document.id())).isEqualTo(1);
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql("SELECT workflow_status FROM document WHERE id = :id")
|
||||
.param("id", document.id())
|
||||
.query(String.class)
|
||||
.single())
|
||||
.isEqualTo("PUBLISHED");
|
||||
|
||||
// 재게시는 REPUBLISHED 이벤트와 revision 증가를 만든다.
|
||||
PublishResultView republished =
|
||||
transactions.execute(
|
||||
status ->
|
||||
publicationWriter.publish(
|
||||
new PublicationWriterPort.PublishRequest(
|
||||
RecordKind.CASE,
|
||||
document.id(),
|
||||
document.version(),
|
||||
"게시 대상",
|
||||
"요약",
|
||||
topicId,
|
||||
projectId,
|
||||
"/cases/publish-target",
|
||||
null,
|
||||
"{\"kind\":\"CASE\"}",
|
||||
"본문 평문",
|
||||
List.of(),
|
||||
"1",
|
||||
"1",
|
||||
"idem-2",
|
||||
"tester")));
|
||||
assertThat(republished.event().type())
|
||||
.isEqualTo(
|
||||
dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView.REPUBLISHED);
|
||||
assertThat(republished.publication().publicationRevision()).isEqualTo(2L);
|
||||
|
||||
var page = publicationHistory.list(new ListPublicationsQuery(null, null, null, 20));
|
||||
assertThat(page.items()).isNotEmpty();
|
||||
assertThat(page.items().getFirst().document()).isNotNull();
|
||||
assertThat(publicationHistory.findSnapshot(published.event().publicationEventId())).isPresent();
|
||||
|
||||
PublishResultView withdrawn =
|
||||
transactions.execute(
|
||||
status ->
|
||||
publicationWriter.unpublish(
|
||||
new PublicationWriterPort.UnpublishRequest(
|
||||
published.publication().publicationId(),
|
||||
RecordKind.CASE,
|
||||
document.id(),
|
||||
republished.publication().publicationRevision(),
|
||||
"tester")));
|
||||
assertThat(withdrawn.publication().status()).isEqualTo(PublicationAggregateStatus.UNPUBLISHED);
|
||||
assertThat(withdrawn.event().sourcePublishedEventId())
|
||||
.as("UNPUBLISHED 이벤트는 마지막 공개 Snapshot 을 반드시 참조한다")
|
||||
.isNotNull();
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_state FROM public_resource_projection"
|
||||
+ " WHERE resource_id = :id")
|
||||
.param("id", document.id())
|
||||
.query(String.class)
|
||||
.single())
|
||||
.isEqualTo("WITHDRAWN");
|
||||
assertThat(count("public_route", "resource_id", document.id()))
|
||||
.as("게시 취소는 주소를 지우지 않는다 — 지우면 공개된 링크가 끊긴다")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashboardCountsUseTheSameProjectionAsTheList() {
|
||||
var totals = dashboard.totals();
|
||||
assertThat(totals.documents()).isPositive();
|
||||
assertThat(dashboard.topByNextAction(List.of(NextAction.VALIDATE), 5)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void assetsRoundTripAndReportTheirUsage() {
|
||||
UUID assetId = UUID.randomUUID();
|
||||
AssetView created =
|
||||
assets.create(
|
||||
new AssetRepositoryPort.NewAsset(
|
||||
assetId,
|
||||
"diagram-key",
|
||||
AssetKindView.DIAGRAM,
|
||||
"image/png",
|
||||
"techlog/assets/" + assetId,
|
||||
"diagram.png",
|
||||
1024L,
|
||||
800,
|
||||
600,
|
||||
"a".repeat(64),
|
||||
"설명",
|
||||
false,
|
||||
AssetManagementStatusView.READY),
|
||||
"tester");
|
||||
|
||||
assertThat(created.version()).as("계약의 Asset.version 은 minimum 1 이다").isEqualTo(1L);
|
||||
assertThat(created.publicPath()).isEqualTo("/media/" + assetId);
|
||||
assertThat(created.usageCount()).isZero();
|
||||
|
||||
var page =
|
||||
assets.list(new ListAssetsQuery("diagram", AssetKindView.DIAGRAM, null, null, null, 20));
|
||||
assertThat(page.items()).extracting(AssetView::id).contains(assetId);
|
||||
|
||||
AssetView updated =
|
||||
assets
|
||||
.update(
|
||||
assetId,
|
||||
created.version(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
Boolean.TRUE,
|
||||
AssetManagementStatusView.ARCHIVED,
|
||||
"tester")
|
||||
.orElseThrow();
|
||||
assertThat(updated.altText()).as("altTextProvided=true 는 null 로 지우는 것을 뜻한다").isNull();
|
||||
assertThat(updated.decorative()).isTrue();
|
||||
assertThat(updated.managementStatus()).isEqualTo(AssetManagementStatusView.ARCHIVED);
|
||||
assertThat(updated.version()).isEqualTo(2L);
|
||||
|
||||
assertThat(assets.update(assetId, 99L, null, null, false, null, null, "tester")).isEmpty();
|
||||
assertThat(assets.findObjectKey(assetId)).contains("techlog/assets/" + assetId);
|
||||
|
||||
var detail = assets.findDetail(assetId).orElseThrow();
|
||||
assertThat(detail.hasPublicationHistory()).isFalse();
|
||||
assertThat(detail.usages()).isEmpty();
|
||||
|
||||
assets.delete(assetId);
|
||||
assertThat(assets.find(assetId)).isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> columnsOf(String table) {
|
||||
return jdbcClient
|
||||
.sql("SELECT column_name FROM information_schema.columns WHERE table_name = :table")
|
||||
.param("table", table)
|
||||
.query(String.class)
|
||||
.list();
|
||||
}
|
||||
|
||||
private static boolean isNullable(String table, String column) {
|
||||
return "YES"
|
||||
.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT is_nullable FROM information_schema.columns"
|
||||
+ " WHERE table_name = :table AND column_name = :column")
|
||||
.param("table", table)
|
||||
.param("column", column)
|
||||
.query(String.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
private static String typeOf(String table, String column) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT data_type FROM information_schema.columns"
|
||||
+ " WHERE table_name = :table AND column_name = :column")
|
||||
.param("table", table)
|
||||
.param("column", column)
|
||||
.query(String.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private static String storedDecisionStatus(UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT decision_status FROM project_decision WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(String.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private static int count(String table, String column, UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :id")
|
||||
.param("id", id)
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user