feat: Flyway V5 community schema 基线 + 迁移验证集成测试(T3-01)
从目标模型 718~875 行提取 community 全部 8 表(含 topics,ADR-018 表建功能剪、 无种子进生产链);启用 pg_trgm(02 号发现 V1 漏建,trgm 索引照建);剥离 2 条 跨 schema FK:posts.generation_job_id→creation(M4 补回)、posts.region_id→ platform.regions(M5 地区体系补回),裸可空 uuid 列与索引保留,照 V3 剪 marketplace FK 先例。CommunityMigrationIntegrationTest 8 例验证结构与裁剪。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
V5 baseline: community schema (M3 社区), extracted from
|
||||
patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model,
|
||||
lines 718-875) per iteration-3 T3-01 (ADR-017/ADR-018).
|
||||
|
||||
Scope notes:
|
||||
- All 8 community objects are created here: posts, post_media, comments,
|
||||
post_likes, post_bookmarks, user_follows, topics, post_topics. The topics
|
||||
tables follow the target model even though the topic feature itself is cut
|
||||
from the M3 MVP (ADR-018): structure tracks the target model, feature
|
||||
rollout does not. No topic seed data enters the production chain.
|
||||
- pg_trgm is enabled here: V1 created only pgcrypto and citext, but
|
||||
ix_posts_content_trgm (gin_trgm_ops) requires it (iteration-3/02 finding).
|
||||
citext is repeated with IF NOT EXISTS for idempotence; it exists since V1.
|
||||
- Cross-schema FKs into schemas not yet migrated are STRIPPED (mandatory
|
||||
cut, T3-01), same precedent as V3's four marketplace FK cuts:
|
||||
* posts.generation_job_id -> creation.generation_jobs(id) ON DELETE SET NULL
|
||||
(creation schema belongs to M4; the M4 migration that creates the
|
||||
creation schema re-adds this constraint — M4 补回)
|
||||
* posts.region_id -> platform.regions(id) ON DELETE SET NULL
|
||||
(the region system belongs to M5 per ADR-018's scope cut; the M5
|
||||
region-system migration re-adds this constraint — M5 补回)
|
||||
Both columns are kept as bare nullable uuid and their indexes
|
||||
(ix_posts_generation_job, ix_posts_region, ix_posts_region_feed) are
|
||||
created as modeled.
|
||||
- FKs to identity.users, pet_health.pets and media.assets are kept as-is:
|
||||
all three schemas exist since V1/V3 (same shared-database precedent).
|
||||
- Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3 同规);
|
||||
application code supplies explicit UUIDv7 ids, the default is a fallback.
|
||||
- updated_at triggers on posts/comments reuse platform.set_updated_at()
|
||||
created in V1.
|
||||
- Structure only; no seed data.
|
||||
- Never edit this file after release; subsequent changes go into V6+.
|
||||
*/
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE SCHEMA community;
|
||||
|
||||
COMMENT ON SCHEMA community IS 'Posts, comments, reactions, follows and topics';
|
||||
|
||||
CREATE TABLE community.posts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
|
||||
-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)
|
||||
generation_job_id uuid,
|
||||
category varchar(24) NOT NULL DEFAULT 'general',
|
||||
title varchar(120),
|
||||
content text NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||
visibility varchar(16) NOT NULL DEFAULT 'public',
|
||||
-- region_id: bare nullable uuid, FK to platform.regions stripped (M5 补回)
|
||||
region_id uuid,
|
||||
location_text_snapshot varchar(128),
|
||||
like_count bigint NOT NULL DEFAULT 0,
|
||||
comment_count bigint NOT NULL DEFAULT 0,
|
||||
bookmark_count bigint NOT NULL DEFAULT 0,
|
||||
idempotency_key varchar(128),
|
||||
request_hash bytea,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz,
|
||||
deleted_at timestamptz,
|
||||
version integer NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uq_posts_author_idempotency UNIQUE (author_user_id, idempotency_key),
|
||||
CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation')),
|
||||
CONSTRAINT ck_posts_title CHECK (title IS NULL OR char_length(btrim(title)) BETWEEN 1 AND 120),
|
||||
CONSTRAINT ck_posts_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 10000),
|
||||
CONSTRAINT ck_posts_status CHECK (status IN ('draft', 'published', 'hidden', 'archived')),
|
||||
CONSTRAINT ck_posts_visibility CHECK (visibility IN ('public', 'followers', 'private')),
|
||||
CONSTRAINT ck_posts_publish_state CHECK (
|
||||
(status = 'published' AND published_at IS NOT NULL AND deleted_at IS NULL)
|
||||
OR status <> 'published'
|
||||
),
|
||||
CONSTRAINT ck_posts_counts CHECK (like_count >= 0 AND comment_count >= 0 AND bookmark_count >= 0),
|
||||
CONSTRAINT ck_posts_idempotency CHECK (
|
||||
(idempotency_key IS NULL AND request_hash IS NULL)
|
||||
OR (
|
||||
idempotency_key = btrim(idempotency_key)
|
||||
AND char_length(idempotency_key) BETWEEN 1 AND 128
|
||||
AND octet_length(request_hash) = 32
|
||||
)
|
||||
),
|
||||
CONSTRAINT ck_posts_version CHECK (version >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_posts_feed
|
||||
ON community.posts (published_at DESC, id DESC)
|
||||
WHERE status = 'published' AND visibility = 'public';
|
||||
CREATE INDEX ix_posts_author_created
|
||||
ON community.posts (author_user_id, created_at DESC, id DESC);
|
||||
CREATE INDEX ix_posts_pet_created ON community.posts (pet_id, created_at DESC);
|
||||
CREATE INDEX ix_posts_generation_job ON community.posts (generation_job_id);
|
||||
CREATE INDEX ix_posts_region_feed
|
||||
ON community.posts (region_id, published_at DESC, id DESC)
|
||||
WHERE status = 'published' AND visibility = 'public';
|
||||
CREATE INDEX ix_posts_region ON community.posts (region_id);
|
||||
CREATE INDEX ix_posts_content_trgm
|
||||
ON community.posts USING gin (content gin_trgm_ops)
|
||||
WHERE status = 'published';
|
||||
|
||||
CREATE TABLE community.post_media (
|
||||
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||
position smallint NOT NULL,
|
||||
asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
|
||||
is_cover boolean NOT NULL DEFAULT false,
|
||||
caption varchar(300),
|
||||
PRIMARY KEY (post_id, position),
|
||||
UNIQUE (post_id, asset_id),
|
||||
CONSTRAINT ck_post_media_position CHECK (position >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_post_media_asset ON community.post_media (asset_id);
|
||||
CREATE UNIQUE INDEX uq_post_media_cover
|
||||
ON community.post_media (post_id)
|
||||
WHERE is_cover;
|
||||
|
||||
-- Comments are deliberately one flat level. reply_to_user_id supports @ replies
|
||||
-- without parent_comment_id, recursive queries, or unbounded comment nesting.
|
||||
CREATE TABLE community.comments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||
reply_to_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL,
|
||||
content varchar(2000) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'visible',
|
||||
client_request_id varchar(128),
|
||||
request_hash bytea,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
UNIQUE (author_user_id, client_request_id),
|
||||
CONSTRAINT ck_comments_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 2000),
|
||||
CONSTRAINT ck_comments_status CHECK (status IN ('visible', 'hidden', 'deleted')),
|
||||
CONSTRAINT ck_comments_idempotency CHECK (
|
||||
(client_request_id IS NULL AND request_hash IS NULL)
|
||||
OR (
|
||||
client_request_id = btrim(client_request_id)
|
||||
AND char_length(client_request_id) BETWEEN 1 AND 128
|
||||
AND octet_length(request_hash) = 32
|
||||
)
|
||||
),
|
||||
CONSTRAINT ck_comments_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ix_comments_post_created
|
||||
ON community.comments (post_id, created_at DESC, id DESC);
|
||||
CREATE INDEX ix_comments_author_created
|
||||
ON community.comments (author_user_id, created_at DESC, id DESC);
|
||||
CREATE INDEX ix_comments_reply_user
|
||||
ON community.comments (reply_to_user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE community.post_likes (
|
||||
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (post_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_post_likes_user_created
|
||||
ON community.post_likes (user_id, created_at DESC, post_id);
|
||||
|
||||
CREATE TABLE community.post_bookmarks (
|
||||
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (post_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_post_bookmarks_user_created
|
||||
ON community.post_bookmarks (user_id, created_at DESC, post_id);
|
||||
|
||||
CREATE TABLE community.user_follows (
|
||||
follower_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
followee_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (follower_user_id, followee_user_id),
|
||||
CONSTRAINT ck_user_follows_self CHECK (follower_user_id <> followee_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_user_follows_followee
|
||||
ON community.user_follows (followee_user_id, created_at DESC, follower_user_id);
|
||||
|
||||
CREATE TABLE community.topics (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name citext NOT NULL UNIQUE,
|
||||
description varchar(300),
|
||||
status varchar(16) NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_topics_name CHECK (name::text = btrim(name::text) AND char_length(name::text) BETWEEN 1 AND 32),
|
||||
CONSTRAINT ck_topics_status CHECK (status IN ('active', 'hidden'))
|
||||
);
|
||||
|
||||
CREATE TABLE community.post_topics (
|
||||
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||
topic_id uuid NOT NULL REFERENCES community.topics(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, topic_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_post_topics_topic ON community.post_topics (topic_id, post_id);
|
||||
|
||||
-- Automatic updated_at maintenance (function created in V1). Business version
|
||||
-- increments remain explicit so optimistic locking stays visible in
|
||||
-- repository update statements.
|
||||
CREATE TRIGGER trg_posts_updated_at BEFORE UPDATE ON community.posts
|
||||
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||
CREATE TRIGGER trg_comments_updated_at BEFORE UPDATE ON community.comments
|
||||
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.patbond.patbond.user.persistence;
|
||||
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Flyway V5 (community schema baseline) applies cleanly on a postgres:18
|
||||
* container after V1..V4 and produces the expected structure. Validates the
|
||||
* mandatory cross-schema FK cuts (ADR-017/ADR-018, T3-01):
|
||||
* generation_job_id (creation belongs to M4) and region_id (regions belong
|
||||
* to the M5 region system) exist as bare nullable uuid columns without FKs,
|
||||
* and the pg_trgm extension missing from V1 is now installed.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class CommunityMigrationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@Test
|
||||
void v5CreatesCommunitySchema() {
|
||||
String exists = jdbcClient.sql(
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'community'")
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
assertThat(exists).isEqualTo("community");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5CreatesAllCommunityTables() {
|
||||
int count = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = 'community' AND table_type = 'BASE TABLE'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
// 8 tables: posts, post_media, comments, post_likes, post_bookmarks,
|
||||
// user_follows, topics, post_topics (topics built per target model
|
||||
// even though the topic feature is cut from the M3 MVP, ADR-018).
|
||||
assertThat(count).isEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5EnablesPgTrgmAndBuildsContentTrgmIndex() {
|
||||
int extension = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_trgm'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(extension).isEqualTo(1);
|
||||
|
||||
int index = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM pg_indexes " +
|
||||
"WHERE schemaname = 'community' AND tablename = 'posts' " +
|
||||
"AND indexname = 'ix_posts_content_trgm'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(index).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5PostsColumnsExistWithoutCreationAndRegionFKs() {
|
||||
// generation_job_id and region_id columns exist (bare nullable uuid)
|
||||
int colCount = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||
"AND column_name IN ('generation_job_id', 'region_id') " +
|
||||
"AND udt_name = 'uuid' AND is_nullable = 'YES'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(colCount).isEqualTo(2);
|
||||
|
||||
// No FK constraints targeting creation.generation_jobs (M4) or
|
||||
// platform.regions (M5 region system)
|
||||
int fkCount = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||
"AND constraint_type = 'FOREIGN KEY' " +
|
||||
"AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(fkCount).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5PostsKeepsInSchemaAndV1V3FKs() {
|
||||
// author_user_id -> identity.users and pet_id -> pet_health.pets stay,
|
||||
// both target schemas exist since V1/V3.
|
||||
int fkCount = jdbcClient.sql(
|
||||
"SELECT COUNT(DISTINCT ccu.table_schema || '.' || ccu.table_name) " +
|
||||
"FROM information_schema.table_constraints tc " +
|
||||
"JOIN information_schema.constraint_column_usage ccu " +
|
||||
" ON tc.constraint_name = ccu.constraint_name " +
|
||||
" AND tc.constraint_schema = ccu.constraint_schema " +
|
||||
"WHERE tc.table_schema = 'community' AND tc.table_name = 'posts' " +
|
||||
"AND tc.constraint_type = 'FOREIGN KEY' " +
|
||||
"AND ccu.table_schema || '.' || ccu.table_name " +
|
||||
" IN ('identity.users', 'pet_health.pets')")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(fkCount).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5StructureSpotChecks() {
|
||||
// topics.name is citext (case-insensitive unique topic names)
|
||||
String nameType = jdbcClient.sql(
|
||||
"SELECT udt_name FROM information_schema.columns " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'topics' " +
|
||||
"AND column_name = 'name'")
|
||||
.query(String.class)
|
||||
.single();
|
||||
assertThat(nameType).isEqualTo("citext");
|
||||
|
||||
String versionDefault = jdbcClient.sql(
|
||||
"SELECT column_default FROM information_schema.columns " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||
"AND column_name = 'version'")
|
||||
.query(String.class)
|
||||
.single();
|
||||
assertThat(versionDefault).isEqualTo("0");
|
||||
|
||||
// partial unique cover index on post_media
|
||||
int coverIndex = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM pg_indexes " +
|
||||
"WHERE schemaname = 'community' AND tablename = 'post_media' " +
|
||||
"AND indexname = 'uq_post_media_cover'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(coverIndex).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5TriggersAreCreated() {
|
||||
// The two updated_at triggers on posts and comments
|
||||
int triggerCount = jdbcClient.sql(
|
||||
"SELECT COUNT(DISTINCT trigger_name) FROM information_schema.triggers " +
|
||||
"WHERE trigger_schema = 'community' " +
|
||||
"AND trigger_name IN ('trg_posts_updated_at', 'trg_comments_updated_at')")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(triggerCount).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5TopicsHaveNoSeedRows() {
|
||||
// ADR-018: no topic seed data enters the production migration chain.
|
||||
int rows = jdbcClient.sql("SELECT COUNT(*) FROM community.topics")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(rows).isZero();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user