feat(document): add undatedOnly Specification for the undated-only filter
undatedOnly(false) is a no-op (null predicate); undatedOnly(true) returns documentDate IS NULL, matching the existing hasStatus null-as-no-op pattern. Real-Postgres tests pin the load-bearing guarantees H2 cannot prove: ASC NULLS-LAST ordering, BETWEEN excludes null-dated rows, and that undated=true combined with a from/to range returns empty (the collision rule). Refs #668
This commit is contained in:
@@ -55,6 +55,12 @@ public class DocumentSpecifications {
|
||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||
}
|
||||
|
||||
// Filtert auf undatierte Dokumente (meta_date IS NULL) — für die "Nur undatierte"-Triage.
|
||||
// false → kein Prädikat (no-op), true → documentDate IS NULL (issue #668).
|
||||
public static Specification<Document> undatedOnly(boolean undated) {
|
||||
return (root, query, cb) -> undated ? cb.isNull(root.get("documentDate")) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik.
|
||||
*
|
||||
|
||||
@@ -261,4 +261,21 @@ class DocumentSpecificationsTest {
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── undatedOnly ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void undatedOnly_false_returnsAllDocuments() {
|
||||
// false → no predicate (null), so the filter is a no-op (issue #668).
|
||||
List<Document> result = documentRepository.findAll(Specification.where(undatedOnly(false)));
|
||||
assertThat(result).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedOnly_true_returnsOnlyDocumentsWithoutADate() {
|
||||
// Only the placeholder photo has a null documentDate in the fixture.
|
||||
List<Document> result = documentRepository.findAll(Specification.where(undatedOnly(true)));
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
|
||||
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.DocumentSpecifications.isBetween;
|
||||
import static org.raddatz.familienarchiv.document.DocumentSpecifications.undatedOnly;
|
||||
|
||||
/**
|
||||
* Real-Postgres assertions for issue #668. H2 disagrees with Postgres on
|
||||
* {@code NULLS FIRST/LAST} defaults and on whether {@code BETWEEN} excludes
|
||||
* NULL, so these guarantees MUST run against {@code postgres:16-alpine}, never
|
||||
* an in-memory database.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class UndatedDocumentOrderingIntegrationTest {
|
||||
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository.deleteAll();
|
||||
save("1916", LocalDate.of(1916, 6, 15));
|
||||
save("1943", LocalDate.of(1943, 12, 24));
|
||||
save("undated-a", null);
|
||||
save("undated-b", null);
|
||||
}
|
||||
|
||||
private void save(String title, LocalDate date) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title(title)
|
||||
.originalFilename(title + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(date == null ? DatePrecision.UNKNOWN : DatePrecision.DAY)
|
||||
.documentDate(date)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateAscWithNullsLast_returnsDatedFirstUndatedLast() {
|
||||
Sort sort = Sort.by(new Sort.Order(Sort.Direction.ASC, "documentDate").nullsLast());
|
||||
|
||||
List<Document> result = documentRepository.findAll(sort);
|
||||
|
||||
assertThat(result).hasSize(4);
|
||||
assertThat(result.get(0).getDocumentDate()).isEqualTo(LocalDate.of(1916, 6, 15));
|
||||
assertThat(result.get(1).getDocumentDate()).isEqualTo(LocalDate.of(1943, 12, 24));
|
||||
assertThat(result.get(2).getDocumentDate()).isNull();
|
||||
assertThat(result.get(3).getDocumentDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedOnly_returnsExactlyTheNullDatedRows() {
|
||||
List<Document> result = documentRepository.findAll(undatedOnly(true));
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedOnly_false_returnsAllRows() {
|
||||
Specification<Document> spec = Specification.where(undatedOnly(false));
|
||||
|
||||
List<Document> result = documentRepository.findAll(spec);
|
||||
|
||||
assertThat(result).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateRange_excludesUndatedRows() {
|
||||
List<Document> result = documentRepository.findAll(isBetween(
|
||||
LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31)));
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).allMatch(d -> d.getDocumentDate() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedOnly_combinedWithDateRange_returnsEmpty() {
|
||||
// The collision rule (#668): a from/to range and undated=true are mutually
|
||||
// exclusive — a row cannot both have a null date and fall inside a range.
|
||||
Specification<Document> spec = Specification
|
||||
.where(undatedOnly(true))
|
||||
.and(isBetween(LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31)));
|
||||
|
||||
List<Document> result = documentRepository.findAll(spec);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user