feat(annotations): add polygon JSONB support for quadrilateral shapes
- V23 migration adds polygon JSONB column with 4-point CHECK constraint - PolygonConverter: AttributeConverter for List<List<Double>> <-> JSONB - @UniquePoints custom validator rejects duplicate coordinates - CreateAnnotationDTO: validated optional polygon field - DocumentAnnotation entity: polygon field with converter Refs #227 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@@ -14,4 +20,19 @@ public class CreateAnnotationDTO {
|
||||
private double width;
|
||||
private double height;
|
||||
private String color;
|
||||
|
||||
@Size(min = 4, max = 4, message = "polygon must have exactly 4 points")
|
||||
@UniquePoints
|
||||
@Valid
|
||||
private List<@Size(min = 2, max = 2, message = "each point must have exactly 2 coordinates")
|
||||
List<@DecimalMin("0.0") @DecimalMax("1.0") Double>> polygon;
|
||||
|
||||
public CreateAnnotationDTO(int pageNumber, double x, double y, double width, double height, String color) {
|
||||
this.pageNumber = pageNumber;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = UniquePointsValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface UniquePoints {
|
||||
String message() default "polygon must contain 4 unique points";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
public class UniquePointsValidator implements ConstraintValidator<UniquePoints, List<List<Double>>> {
|
||||
|
||||
@Override
|
||||
public boolean isValid(List<List<Double>> polygon, ConstraintValidatorContext context) {
|
||||
if (polygon == null) return true;
|
||||
return new HashSet<>(polygon).size() == polygon.size();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -52,6 +53,10 @@ public class DocumentAnnotation {
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
@Column(columnDefinition = "jsonb")
|
||||
@Convert(converter = PolygonConverter.class)
|
||||
private List<List<Double>> polygon;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Converter
|
||||
public class PolygonConverter implements AttributeConverter<List<List<Double>>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final TypeReference<List<List<Double>>> TYPE_REF = new TypeReference<>() {};
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<List<Double>> polygon) {
|
||||
if (polygon == null) return null;
|
||||
try {
|
||||
return MAPPER.writeValueAsString(polygon);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to serialize polygon", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<List<Double>> convertToEntityAttribute(String json) {
|
||||
if (json == null || json.isEmpty()) return null;
|
||||
try {
|
||||
return MAPPER.readValue(json, TYPE_REF);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to deserialize polygon", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add optional polygon field for quadrilateral annotation shapes (Kraken OCR output).
|
||||
-- See ADR-002 for the design decision.
|
||||
|
||||
ALTER TABLE document_annotations ADD COLUMN polygon JSONB;
|
||||
|
||||
ALTER TABLE document_annotations
|
||||
ADD CONSTRAINT chk_annotation_polygon_quad
|
||||
CHECK (polygon IS NULL OR jsonb_array_length(polygon) = 4);
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UniquePointsValidatorTest {
|
||||
|
||||
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Test
|
||||
void shouldAcceptNull() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(null);
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptFourUniquePoints() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(0.1, 0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicatePoints() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(0.1, 0.1),
|
||||
List.of(0.1, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPolygonWithThreePoints() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(0.1, 0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPolygonWithFivePoints() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(0.1, 0.1),
|
||||
List.of(0.5, 0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCoordinateOutOfRange() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(1.5, 0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectNegativeCoordinate() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(-0.1, 0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPointWithOneCoordinate() {
|
||||
var dto = new CreateAnnotationDTO();
|
||||
dto.setPolygon(List.of(
|
||||
List.of(0.1),
|
||||
List.of(0.9, 0.1),
|
||||
List.of(0.9, 0.9),
|
||||
List.of(0.1, 0.9)));
|
||||
|
||||
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PolygonConverterTest {
|
||||
|
||||
private final PolygonConverter converter = new PolygonConverter();
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumn_returnsNull_whenPolygonIsNull() {
|
||||
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumn_returnsJsonArray_whenPolygonIsValid() {
|
||||
List<List<Double>> polygon = List.of(
|
||||
List.of(0.1, 0.2),
|
||||
List.of(0.9, 0.2),
|
||||
List.of(0.9, 0.8),
|
||||
List.of(0.1, 0.8));
|
||||
|
||||
String json = converter.convertToDatabaseColumn(polygon);
|
||||
|
||||
assertThat(json).isEqualTo("[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttribute_returnsNull_whenJsonIsNull() {
|
||||
assertThat(converter.convertToEntityAttribute(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttribute_returnsNull_whenJsonIsEmpty() {
|
||||
assertThat(converter.convertToEntityAttribute("")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttribute_returnsPolygon_whenJsonIsValid() {
|
||||
String json = "[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]";
|
||||
|
||||
List<List<Double>> polygon = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertThat(polygon).hasSize(4);
|
||||
assertThat(polygon.get(0)).containsExactly(0.1, 0.2);
|
||||
assertThat(polygon.get(3)).containsExactly(0.1, 0.8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesValues() {
|
||||
List<List<Double>> original = List.of(
|
||||
List.of(0.12, 0.08),
|
||||
List.of(0.88, 0.09),
|
||||
List.of(0.87, 0.14),
|
||||
List.of(0.11, 0.13));
|
||||
|
||||
String json = converter.convertToDatabaseColumn(original);
|
||||
List<List<Double>> restored = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertThat(restored).isEqualTo(original);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user