feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
DEFERRABLE INITIALLY DEFERRED allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row). CHECK (position > 0) guards against off-by-one in the append path. Both verified by JourneyItemConstraintsTest via raw pg_constraint query + jdbcTemplate inserts against a real postgres:16-alpine container. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.config;
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.Module;
|
||||||
import org.openapitools.jackson.nullable.JsonNullableModule;
|
import org.openapitools.jackson.nullable.JsonNullableModule;
|
||||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
public class JacksonConfig {
|
public class JacksonConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public Jackson2ObjectMapperBuilderCustomizer jsonNullableModule() {
|
public Module jsonNullableModule() {
|
||||||
return builder -> builder.modulesToInstall(new JsonNullableModule());
|
return new JsonNullableModule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Adds the two constraints that V72 deferred:
|
||||||
|
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||||
|
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
|
||||||
|
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
|
||||||
|
-- in transaction mode — correct today; a future switch to statement-level would silently
|
||||||
|
-- break deferred checking at COMMIT).
|
||||||
|
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
|
||||||
|
--
|
||||||
|
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
|
||||||
|
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
|
||||||
|
|
||||||
|
ALTER TABLE journey_items
|
||||||
|
ADD CONSTRAINT uq_journey_items_geschichte_position
|
||||||
|
UNIQUE (geschichte_id, position)
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
ALTER TABLE journey_items
|
||||||
|
ADD CONSTRAINT chk_journey_item_position
|
||||||
|
CHECK (position > 0);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||||
|
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||||
|
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||||
|
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JourneyItemConstraintsTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
private UUID geschichteId;
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Constraints-Test-Doc")
|
||||||
|
.originalFilename("ct.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
documentId = doc.getId();
|
||||||
|
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Constraints-Test-Journey")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
geschichteId = g.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_constraint_is_deferrable_initially_deferred() {
|
||||||
|
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||||
|
Boolean.class);
|
||||||
|
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||||
|
Boolean.class);
|
||||||
|
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||||
|
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void position_check_rejects_nonpositive() {
|
||||||
|
UUID itemId = UUID.randomUUID();
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
itemId, geschichteId, 0, "test"))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user