S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s

This commit is contained in:
2026-11-04 17:10:25 +01:00
parent f59cad115a
commit 694bf3fa86
89 changed files with 3087 additions and 252 deletions

View File

@@ -14,6 +14,11 @@
<packaging>jar</packaging>
<dependencies>
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${mapstruct.version}</version></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-hibernate-orm</artifactId></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jdbc-postgresql</artifactId></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-flyway</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
@@ -63,6 +68,16 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths combine.children="append">
<path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${mapstruct.version}</version></path>
<path><groupId>org.projectlombok</groupId><artifactId>lombok-mapstruct-binding</artifactId><version>${lombok-mapstruct-binding.version}</version></path>
</annotationProcessorPaths>
<compilerArgs><arg>-Amapstruct.suppressGeneratorTimestamp=true</arg></compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>

View File

@@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity {
@Setter
private String name;
@Setter
private long revision;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();

View File

@@ -1,10 +1,7 @@
package nl.agilecc.agiledancers.service.model;
package nl.agilecc.agiledancers.model.scheduling;
import java.time.Instant;
import java.util.UUID;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
@@ -24,7 +21,7 @@ public record SchedulingRun(
schoolId,
showId,
name,
Instant.now(),
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
null,
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
@@ -54,7 +51,7 @@ public record SchedulingRun(
showId,
showName,
startedAt,
Instant.now(),
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,

View File

@@ -1,7 +1,8 @@
package nl.agilecc.agiledancers.service.model;
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingState {
RUNNING,
INTERRUPTED,
OPTIMAL,
VALID,
STALE,

View File

@@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository<T extends AbstractEntity> {
private long sequence = 0L;
private java.util.function.LongSupplier identifiers;
protected final Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
return sequence++;
return identifiers == null ? sequence++ : identifiers.getAsLong();
}
/** Replaces detached transaction state; production identifiers come from PostgreSQL. */
public void load(final java.util.Collection<T> values, final java.util.function.LongSupplier identifiers) {
entities.clear();
values.forEach((final T entity) -> entities.put(entity.getId(), entity));
this.identifiers = identifiers;
}
public T add(final T entity) {

View File

@@ -0,0 +1,105 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PersistenceException;
import jakarta.transaction.Status;
import jakarta.transaction.TransactionManager;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.repository.entity.EntityIdentifiers;
/** Owns short JTA units of work; Hibernate flushes before commit and publication follows durable commit. */
@Singleton
public class Database {
private final EntityManager entities;
private final TransactionManager manager;
private final ThreadLocal<List<Runnable>> committed = new ThreadLocal<>();
private final ThreadLocal<List<Runnable>> rolledBack = new ThreadLocal<>();
@Inject
public Database(final EntityManager entities, final TransactionManager manager) {
this.entities = entities;
this.manager = manager;
}
public <T> T transaction(final Callable<T> work) throws Exception {
try {
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
return work.call();
}
return outerTransaction(work);
} catch (final OptimisticLockException failure) {
throw new RevisionConflictException();
} catch (final PersistenceException
| jakarta.transaction.SystemException
| jakarta.transaction.RollbackException
| jakarta.transaction.HeuristicMixedException
| jakarta.transaction.HeuristicRollbackException
| jakarta.transaction.NotSupportedException failure) {
if (optimisticConflict(failure)) {
throw new RevisionConflictException();
}
System.getLogger(Database.class.getName())
.log(System.Logger.Level.WARNING, "JPA transaction failed", failure);
throw new StorageException(failure);
}
}
private static boolean optimisticConflict(final Throwable failure) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof OptimisticLockException) {
return true;
}
}
return false;
}
private <T> T outerTransaction(final Callable<T> work) throws Exception {
final List<Runnable> publications = new ArrayList<>();
manager.begin();
committed.set(publications);
rolledBack.set(new ArrayList<>());
final T result;
try {
result = work.call();
manager.commit();
} catch (final Exception | Error failure) {
rollback(failure);
rolledBack.get().forEach(Runnable::run);
throw failure;
} finally {
committed.remove();
rolledBack.remove();
}
publications.forEach(Runnable::run);
return result;
}
private void rollback(final Throwable failure) {
try {
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
manager.rollback();
}
} catch (final jakarta.transaction.SystemException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
public void afterCommit(final Runnable action) {
committed.get().add(action);
}
public void afterRollback(final Runnable action) {
rolledBack.get().add(action);
}
public long nextId() {
return entities.find(EntityIdentifiers.class, 1, LockModeType.PESSIMISTIC_WRITE)
.claim();
}
}

View File

@@ -0,0 +1,62 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
import nl.agilecc.agiledancers.repository.entity.GroupEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
public class GroupMapping {
public static Map<Long, DanceGroup> groups(
final SchoolEntity stored,
final DanceSchool school,
final Map<Long, Coach> coaches,
final Map<Long, Dancer> dancers) {
final Map<Long, DanceGroup> result = new LinkedHashMap<>();
for (final GroupEntity entity : stored.getGroups()) {
final DanceGroup group = DanceGroup.builder()
.category(entity.getCategory())
.type(entity.getType())
.startAge(entity.getStartAge())
.endAge(entity.getEndAge())
.coach(
entity.getCoach() == null
? null
: coaches.get(entity.getCoach().getId().getLocalId()))
.styles(new LinkedHashSet<>(entity.getStyles()))
.build();
group.setId(entity.getId().getLocalId());
entity.getDancerIds().stream().sorted().forEach((final Long id) -> group.addDancer(dancers.get(id)));
school.addDanceGroup(group);
result.put(group.getId(), group);
}
return result;
}
public static GroupEntity group(final SchoolEntity school, final DanceGroup group) {
final GroupEntity entity = new GroupEntity();
entity.setId(new SchoolMemberId(school.getId(), group.getId()));
entity.setSchool(school);
entity.setCategory(group.getCategory());
entity.setType(group.getType());
entity.setStartAge(group.getStartAge());
entity.setEndAge(group.getEndAge());
if (group.getCoach() != null) {
entity.setCoach(school.getCoaches().stream()
.filter((final CoachEntity coach) ->
coach.getId().getLocalId() == group.getCoach().getId())
.findFirst()
.orElseThrow());
}
entity.setStyles(new LinkedHashSet<>(group.getStyles()));
group.getDancers()
.forEach((final Dancer dancer) -> entity.getDancerIds().add(dancer.getId()));
return entity;
}
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
import nl.agilecc.agiledancers.repository.entity.DancerEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
/** Converts people while preserving the shared dancer identity used by coach links. */
public class PeopleMapping {
public static Map<Long, Dancer> dancers(final SchoolEntity stored, final DanceSchool school) {
final Map<Long, Dancer> result = new LinkedHashMap<>();
for (final DancerEntity person : stored.getDancers()) {
final Dancer dancer = Dancer.builder()
.name(person.getName())
.birthDate(person.getBirthDate())
.build();
dancer.setId(person.getId().getLocalId());
school.addDancer(dancer);
result.put(dancer.getId(), dancer);
}
return result;
}
public static Map<Long, Coach> coaches(
final SchoolEntity stored, final DanceSchool school, final Map<Long, Dancer> dancers) {
final Map<Long, Coach> result = new LinkedHashMap<>();
for (final CoachEntity trainer : stored.getCoaches()) {
final Coach coach = Coach.builder()
.name(trainer.getName())
.dancer(
trainer.getDancer() == null
? null
: dancers.get(trainer.getDancer().getId().getLocalId()))
.build();
coach.setId(trainer.getId().getLocalId());
school.addCoach(coach);
result.put(coach.getId(), coach);
}
return result;
}
public static DancerEntity dancer(final SchoolEntity school, final Dancer dancer) {
final DancerEntity entity = new DancerEntity();
entity.setId(new SchoolMemberId(school.getId(), dancer.getId()));
entity.setSchool(school);
entity.setName(dancer.getName());
entity.setBirthDate(dancer.getBirthDate());
return entity;
}
public static CoachEntity coach(final SchoolEntity school, final Coach coach) {
final CoachEntity entity = new CoachEntity();
entity.setId(new SchoolMemberId(school.getId(), coach.getId()));
entity.setSchool(school);
entity.setName(coach.getName());
if (coach.getDancer() != null) {
entity.setDancer(school.getDancers().stream()
.filter((final DancerEntity dancer) ->
dancer.getId().getLocalId() == coach.getDancer().getId())
.findFirst()
.orElseThrow());
}
return entity;
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.repository;
/** An optimistic persistence conflict requires a new editor snapshot, never an automatic retry. */
public class RevisionConflictException extends RuntimeException {
public RevisionConflictException() {
super("Deze school is gewijzigd. Herlaad de gegevens en probeer opnieuw.");
}
}

View File

@@ -0,0 +1,78 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.repository.entity.RunEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
@Singleton
public class RunRepository {
private final EntityManager entities;
@Inject
public RunRepository(final EntityManager entities) {
this.entities = entities;
}
public List<SchedulingRun> load() {
return entities
.createQuery("from StoredRun run order by run.id.schoolId,run.id.localId", RunEntity.class)
.getResultList()
.stream()
.map(this::read)
.toList();
}
public void recover() {
load().stream()
.filter((final SchedulingRun run) -> run.state() == SchedulingState.RUNNING)
.forEach((final SchedulingRun run) -> save(run.completed(
SchedulingState.INTERRUPTED,
"Berekening onderbroken door een backendherstart. Geen nieuw resultaat toegepast; bestaande"
+ " indeling behouden. Start de berekening opnieuw.",
null)));
}
public void save(final SchedulingRun run) {
final RunEntity entity = new RunEntity();
entity.setId(new SchoolMemberId(run.schoolId(), run.showId()));
entity.setRunId(run.runId());
entity.setShowName(run.showName());
entity.setStartedAt(run.startedAt());
entity.setFinishedAt(run.finishedAt());
entity.setState(run.state());
entity.setPhase(run.progress().phase());
entity.setFeasible(run.progress().feasible());
entity.setMessage(run.message());
if (run.summary() != null) {
entity.setAttendanceExcess(run.summary().attendanceExcess());
entity.setPerformanceSpread(run.summary().performanceSpread());
entity.setVarietyPenalty(run.summary().varietyPenalty());
}
entities.merge(entity);
}
private SchedulingRun read(final RunEntity entity) {
final SchedulingSummary summary = entity.getAttendanceExcess() == null
? null
: new SchedulingSummary(
entity.getAttendanceExcess(), entity.getPerformanceSpread(), entity.getVarietyPenalty());
return new SchedulingRun(
entity.getRunId(),
entity.getId().getSchoolId(),
entity.getId().getLocalId(),
entity.getShowName(),
entity.getStartedAt(),
entity.getFinishedAt(),
entity.getState(),
new SchedulingProgress(entity.getPhase(), entity.isFeasible()),
entity.getMessage(),
summary);
}
}

View File

@@ -0,0 +1,47 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
/** Exposes detached domain snapshots, keeping Hibernate sessions and lazy collections inside the adapter. */
@Singleton
public class SchoolReader {
private final EntityManager entities;
@Inject
public SchoolReader(final EntityManager entities) {
this.entities = entities;
}
public List<DanceSchool> load() {
return entities
.createQuery("from StoredSchool school order by school.id", SchoolEntity.class)
.getResultList()
.stream()
.map(this::school)
.toList();
}
private DanceSchool school(final SchoolEntity entity) {
final DanceSchool school = DanceSchool.builder()
.key(entity.getKey())
.name(entity.getName())
.revision(entity.getRevision())
.build();
school.setId(entity.getId());
final Map<Long, Dancer> dancers = PeopleMapping.dancers(entity, school);
final Map<Long, Coach> coaches = PeopleMapping.coaches(entity, school, dancers);
final Map<Long, DanceGroup> groups = GroupMapping.groups(entity, school, coaches, dancers);
entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups)));
return school;
}
}

View File

@@ -0,0 +1,82 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.util.List;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.repository.entity.RunEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
/** Hibernate merges the aggregate graph and handles child differences, orphan removal and optimistic versioning. */
@Singleton
public class SchoolWriter {
private final EntityManager entities;
@Inject
public SchoolWriter(final EntityManager entities) {
this.entities = entities;
}
public void checkRevision(final long schoolId, final long expected) {
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId, LockModeType.OPTIMISTIC);
if (school == null || school.getRevision() != expected) {
throw new RevisionConflictException();
}
}
public void save(final DanceSchool school) {
final SchoolEntity existing = entities.find(SchoolEntity.class, school.getId());
final SchoolEntity graph = graph(school);
if (existing == null) {
entities.persist(graph);
} else {
removeDeletedRuns(school);
graph.setRevision(existing.getRevision());
graph.setChangeStamp(existing.getChangeStamp() + 1);
entities.merge(graph);
}
entities.flush();
}
private SchoolEntity graph(final DanceSchool school) {
final SchoolEntity graph = new SchoolEntity();
graph.setId(school.getId());
graph.setKey(school.getKey());
graph.setName(school.getName());
school.getDancers()
.forEach((final Dancer dancer) -> graph.getDancers().add(PeopleMapping.dancer(graph, dancer)));
school.getCoaches().forEach((final Coach coach) -> graph.getCoaches().add(PeopleMapping.coach(graph, coach)));
school.getDanceGroups()
.forEach((final DanceGroup group) -> graph.getGroups().add(GroupMapping.group(graph, group)));
school.getShows().forEach((final Show show) -> graph.getShows().add(ShowMapping.show(graph, show)));
return graph;
}
private void removeDeletedRuns(final DanceSchool school) {
final List<Long> shows = school.getShows().stream().map(Show::getId).toList();
entities
.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
.setParameter("school", school.getId())
.getResultList()
.stream()
.filter((final RunEntity run) -> !shows.contains(run.getId().getLocalId()))
.forEach(entities::remove);
}
public void delete(final long schoolId) {
entities.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
.setParameter("school", schoolId)
.getResultList()
.forEach(entities::remove);
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId);
if (school != null) {
entities.remove(school);
}
}
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.repository.entity.DayPartEntity;
import nl.agilecc.agiledancers.repository.entity.DayPartId;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
public class ShowMapping {
public static Show show(final ShowEntity entity, final Map<Long, DanceGroup> groups) {
final Show show = Show.builder()
.name(entity.getName())
.preferredDayParts(entity.getPreferredDayParts())
.finalized(entity.isFinalized())
.appearances(Map.copyOf(entity.getAppearances()))
.dayParts(entity.getDayParts().stream()
.map((final DayPartEntity part) -> DayPart.builder()
.date(part.getDate())
.type(part.getType())
.danceGroups(part.getGroupIds().stream()
.map(groups::get)
.toList())
.build())
.toList())
.build();
show.setId(entity.getId().getLocalId());
return show;
}
public static ShowEntity show(final SchoolEntity school, final Show show) {
final ShowEntity entity = new ShowEntity();
entity.setId(new SchoolMemberId(school.getId(), show.getId()));
entity.setSchool(school);
entity.setName(show.getName());
entity.setPreferredDayParts(show.getPreferredDayParts());
entity.setFinalized(show.isFinalized());
entity.setAppearances(new LinkedHashMap<>(show.getAppearances()));
for (int index = 0; index < show.getDayParts().size(); index++) {
entity.getDayParts().add(part(entity, index, show.getDayParts().get(index)));
}
return entity;
}
private static DayPartEntity part(final ShowEntity show, final int position, final DayPart part) {
final DayPartEntity entity = new DayPartEntity();
entity.setId(new DayPartId(show.getId().getSchoolId(), show.getId().getLocalId(), position));
entity.setShow(show);
entity.setDate(part.getDate());
entity.setType(part.getType());
part.getDanceGroups()
.forEach((final DanceGroup group) -> entity.getGroupIds().add(group.getId()));
return entity;
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.repository;
/** Storage failures never authorize an in-memory fallback. */
public class StorageException extends RuntimeException {
public StorageException(final Exception cause) {
super("Database niet beschikbaar. De wijziging is niet opgeslagen; herlaad en probeer opnieuw.", cause);
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredCoach")
@Table(name = "coach")
public class CoachEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
// The shared school key is read from the owning row; only the linked member ID is writable.
@ManyToOne(
fetch = FetchType.LAZY,
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
@org.hibernate.annotations.JoinColumnsOrFormulas({
@org.hibernate.annotations.JoinColumnOrFormula(
column = @JoinColumn(name = "dancer_id", referencedColumnName = "id")),
@org.hibernate.annotations.JoinColumnOrFormula(
formula =
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
})
private DancerEntity dancer;
}

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredDancer")
@Table(name = "dancer")
public class DancerEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
@Column(name = "birth_date", nullable = false)
private LocalDate birthDate;
}

View File

@@ -0,0 +1,56 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinColumns;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
@Getter
@Setter
@Entity(name = "StoredDayPart")
@Table(name = "day_part")
public class DayPartEntity {
@EmbeddedId
private DayPartId id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name = "school_id", referencedColumnName = "school_id", insertable = false, updatable = false),
@JoinColumn(name = "show_id", referencedColumnName = "id", insertable = false, updatable = false)
})
private ShowEntity show;
@Column(name = "day", nullable = false)
private LocalDate date;
@Enumerated(EnumType.STRING)
@Column(name = "part_type", nullable = false)
private DayPartType type;
@ElementCollection
@CollectionTable(
name = "assignment",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "show_id", referencedColumnName = "show_id"),
@JoinColumn(name = "part_position", referencedColumnName = "position")
})
@OrderColumn(name = "position")
@Column(name = "group_id", nullable = false)
private List<Long> groupIds = new ArrayList<>();
}

View File

@@ -0,0 +1,26 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class DayPartId implements Serializable {
@jakarta.persistence.Column(name = "school_id")
private long schoolId;
@jakarta.persistence.Column(name = "show_id")
private long showId;
@jakarta.persistence.Column(name = "position")
private int position;
}

View File

@@ -0,0 +1,24 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "EntityIdentifiers")
@Table(name = "entity_identifiers")
public class EntityIdentifiers {
@Id
private int id;
@Column(name = "next_value", nullable = false)
private long nextValue;
public long claim() {
return nextValue++;
}
}

View File

@@ -0,0 +1,72 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@Setter
@Entity(name = "StoredGroup")
@Table(name = "dance_group")
public class GroupEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String category;
@Enumerated(EnumType.STRING)
@Column(name = "group_type", nullable = false)
private DanceGroupType type;
@Column(name = "start_age", nullable = false)
private int startAge;
@Column(name = "end_age", nullable = false)
private int endAge;
// The shared school key is read from the owning row; only the linked member ID is writable.
@ManyToOne(
fetch = FetchType.LAZY,
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
@org.hibernate.annotations.JoinColumnsOrFormulas({
@org.hibernate.annotations.JoinColumnOrFormula(
column = @JoinColumn(name = "coach_id", referencedColumnName = "id")),
@org.hibernate.annotations.JoinColumnOrFormula(
formula =
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
})
private CoachEntity coach;
@ElementCollection
@CollectionTable(
name = "group_style",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "group_id", referencedColumnName = "id")
})
@Enumerated(EnumType.STRING)
@Column(name = "style", nullable = false)
private Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ElementCollection
@CollectionTable(
name = "group_dancer",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "group_id", referencedColumnName = "id")
})
@Column(name = "dancer_id", nullable = false)
private Set<Long> dancerIds = new LinkedHashSet<>();
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
@Getter
@Setter
@Entity(name = "StoredRun")
@Table(name = "scheduling_run")
public class RunEntity {
@EmbeddedId
@AttributeOverride(name = "localId", column = @Column(name = "show_id"))
private SchoolMemberId id;
@Column(name = "run_id", nullable = false)
private UUID runId;
@Column(name = "show_name", nullable = false)
private String showName;
@Column(name = "started_at", nullable = false)
private Instant startedAt;
@Column(name = "finished_at")
private Instant finishedAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SchedulingState state;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SchedulingPhase phase;
private boolean feasible;
@Column(nullable = false)
private String message;
@Column(name = "attendance_excess")
private Long attendanceExcess;
@Column(name = "performance_spread")
private Integer performanceSpread;
@Column(name = "variety_penalty")
private Long varietyPenalty;
}

View File

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredSchool")
@Table(name = "school")
public class SchoolEntity {
@Id
private long id;
@Column(name = "school_key", nullable = false)
private UUID key;
@Column(nullable = false)
private String name;
@Version
private long revision;
// Marks child-only changes so Hibernate advances the aggregate version exactly once.
@Column(name = "change_stamp", nullable = false)
private long changeStamp;
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<DancerEntity> dancers = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<CoachEntity> coaches = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<GroupEntity> groups = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<ShowEntity> shows = new ArrayList<>();
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@MappedSuperclass
public abstract class SchoolMemberEntity {
@EmbeddedId
private SchoolMemberId id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", insertable = false, updatable = false)
private SchoolEntity school;
}

View File

@@ -0,0 +1,23 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class SchoolMemberId implements Serializable {
@jakarta.persistence.Column(name = "school_id")
private long schoolId;
@jakarta.persistence.Column(name = "id")
private long localId;
}

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapKeyColumn;
import jakarta.persistence.MapKeyEnumerated;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@Setter
@Entity(name = "StoredShow")
@Table(name = "show")
public class ShowEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
@Column(name = "preferred_day_parts", nullable = false)
private int preferredDayParts;
private boolean finalized;
@ElementCollection
@CollectionTable(
name = "show_appearance",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "show_id", referencedColumnName = "id")
})
@MapKeyEnumerated(EnumType.STRING)
@MapKeyColumn(name = "group_type")
@Column(name = "appearances", nullable = false)
private Map<DanceGroupType, Integer> appearances = new LinkedHashMap<>();
@OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.position")
private List<DayPartEntity> dayParts = new ArrayList<>();
}

View File

@@ -15,29 +15,33 @@ import jakarta.ws.rs.core.MediaType;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.CoachLinks;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/coaches")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoachResource {
private @Inject DomainViewMapper views;
private @Inject CoachLinks links;
private @PathParam("schoolId") long schoolId;
@GET
public List<CoachView> list() {
return links.list(schoolId).stream().map(CoachView::of).toList();
return links.list(schoolId).stream().map(views::coach).toList();
}
@POST
public CoachView create(final @NotNull @Valid CoachRequest request) {
return CoachView.of(links.link(schoolId, null, request.dancerId()));
return views.coach(links.link(schoolId, null, request.dancerId()));
}
@PUT
@Path("/{id}")
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
return CoachView.of(links.link(schoolId, id, request.dancerId()));
return views.coach(links.link(schoolId, id, request.dancerId()));
}
@DELETE

View File

@@ -19,26 +19,30 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(GroupView::of).toList();
return service.groups(schoolId).stream().map(views::group).toList();
}
@GET
@Path("/{id}")
public GroupView get(final @PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
return views.group(service.group(schoolId, id));
}
@POST
@@ -50,14 +54,14 @@ public class DanceGroupResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(GroupView.of(entity))
.entity(views.group(entity))
.build();
}
@PUT
@Path("/{id}")
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(
return views.group(service.updateGroup(
schoolId,
id,
new GroupDetails(

View File

@@ -19,23 +19,44 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules;
@GET
@Path("/{id}/data")
public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) {
final DanceSchool school = service.school(id);
return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView(
views.school(school),
school.getDancers().stream().map(views::dancer).toList(),
school.getDanceGroups().stream().map(views::group).toList(),
school.getShows().stream()
.map((final nl.agilecc.agiledancers.model.Show show) ->
views.show(show, schedules.violations(id, show.getId())))
.toList(),
school.getCoaches().stream().map(views::coach).toList());
}
@GET
public List<SchoolView> list() {
return service.schools().stream().map(SchoolView::of).toList();
return service.schools().stream().map(views::school).toList();
}
@GET
@Path("/{id}")
public SchoolView get(final @PathParam("id") long id) {
return SchoolView.of(service.school(id));
return views.school(service.school(id));
}
@POST
@@ -44,14 +65,14 @@ public class DanceSchoolResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(SchoolView.of(entity))
.entity(views.school(entity))
.build();
}
@PUT
@Path("/{id}")
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
return views.school(service.updateSchool(id, request.name()));
}
@DELETE

View File

@@ -19,25 +19,29 @@ import java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(DancerView::of).toList();
return service.dancers(schoolId).stream().map(views::dancer).toList();
}
@GET
@Path("/{id}")
public DancerView get(final @PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
return views.dancer(service.dancer(schoolId, id));
}
@POST
@@ -46,14 +50,14 @@ public class DancerResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(DancerView.of(entity))
.entity(views.dancer(entity))
.build();
}
@PUT
@Path("/{id}")
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE

View File

@@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
public Response toResponse(final ManagementException exception) {
final int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case STORAGE_UNAVAILABLE -> 503;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};

View File

@@ -9,9 +9,9 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import org.jboss.resteasy.reactive.RestStreamElementType;
@Path("/api/scheduling/events")
@@ -36,6 +36,9 @@ public class SchedulingEventsResource {
.every(Duration.ofSeconds(15))
.onItem()
.transform((final Long tick) -> new SchedulingEventView(null));
return Multi.createBy().merging().streams(updates, heartbeat);
return Multi.createBy()
.merging()
.streams(updates, heartbeat)
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
}
}

View File

@@ -0,0 +1,50 @@
package nl.agilecc.agiledancers.rest;
import io.vertx.ext.web.RoutingContext;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolTransactions;
import nl.agilecc.agiledancers.service.model.SchoolChange;
@SchoolTransactionBoundary
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class SchoolRequestInterceptor {
private static final Pattern SCHOOL_PATH = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
private @Inject RoutingContext context;
private @Inject SchoolTransactions transactions;
@AroundInvoke
public Object invoke(final InvocationContext invocation) throws Exception {
final String path = context.request().path();
final String method = context.request().method().name();
final boolean mutation = !method.equals("GET") && !path.endsWith("/preview");
final Matcher matcher = SCHOOL_PATH.matcher(path);
final Long schoolId = matcher.find()
? Long.valueOf(matcher.group(1))
: number(context.request().getParam("replaceId"));
final Long revision = mutation ? number(context.request().getHeader("X-School-Revision")) : null;
return transactions.request(
new SchoolChange(schoolId, revision, mutation, mutation && !path.endsWith("/schedule")),
invocation::proceed);
}
private static Long number(final String value) {
if (value == null) {
return null;
}
try {
return Long.valueOf(value);
} catch (final NumberFormatException failure) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Ongeldige schoolversie of school-ID.");
}
}
}

View File

@@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject ObjectMapper mapper;
@@ -48,7 +52,7 @@ public class SchoolSnapshotResource {
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
return SchoolView.of(service.importSchool(parse(json), replaceId));
return views.school(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(final String json) {

View File

@@ -0,0 +1,13 @@
package nl.agilecc.agiledancers.rest;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Bounds school HTTP operations to one durable transaction and optimistic revision check. */
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface SchoolTransactionBoundary {}

View File

@@ -18,19 +18,23 @@ import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject SchedulingRuns scheduling;
@@ -67,7 +71,7 @@ public class ShowResource {
}
private ShowView view(final Show show) {
return ShowView.of(show, schedules.violations(schoolId, show.getId()));
return views.show(show, schedules.violations(schoolId, show.getId()));
}
private static Show settings(final ShowRequest request) {

View File

@@ -1,12 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Coach;
public record CoachView(long id, String name, Long dancerId) {
public static CoachView of(final Coach coach) {
return new CoachView(
coach.getId(),
coach.getName(),
coach.getDancer() == null ? null : coach.getDancer().getId());
}
}
public record CoachView(long id, String name, Long dancerId) {}

View File

@@ -1,10 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(final Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}
public record DancerView(long id, String name, LocalDate birthDate) {}

View File

@@ -5,8 +5,6 @@ import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -22,15 +20,4 @@ public record GroupView(
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
public static GroupView of(final DanceGroup group) {
return new GroupView(
group.getId(),
group.getCategory(),
group.getType(),
group.getStartAge(),
group.getEndAge(),
Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
public record SchedulingEventView(List<SchedulingRun> runs) {

View File

@@ -0,0 +1,18 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
/** One school and all editor data from the same revision and database snapshot. */
public record SchoolDataView(
SchoolView school,
List<DancerView> dancers,
List<GroupView> groups,
List<ShowView> shows,
List<CoachView> coaches) {
public SchoolDataView {
dancers = List.copyOf(dancers);
groups = List.copyOf(groups);
shows = List.copyOf(shows);
coaches = List.copyOf(coaches);
}
}

View File

@@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
public record SchoolView(long id, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public record SchoolView(
long id, long revision, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public SchoolView {
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
}
public static SchoolView of(final DanceSchool school) {
return new SchoolView(
school.getId(),
school.getName(),
school.getDancers().stream().map(Dancer::getId).toList(),
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
school.getShows().stream().map(Show::getId).toList());
}
}

View File

@@ -5,9 +5,6 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(
@@ -23,26 +20,4 @@ public record ShowView(
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
public static ShowView of(final Show show) {
return of(show, List.of());
}
public static ShowView of(final Show show, final List<String> violations) {
return new ShowView(
show.getId(),
show.getName(),
show.getDayParts().stream()
.map((final DayPart p) -> new DayPartView(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList()))
.toList(),
show.getAppearances(),
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
show.isFinalized(),
violations);
}
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.rest.mapping;
import java.util.List;
import nl.agilecc.agiledancers.model.AbstractEntity;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.rest.apimodel.DayPartView;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingInputs;
import org.mapstruct.AnnotateWith;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.Named;
import org.mapstruct.ReportingPolicy;
/** Generates API projections only; domain graph identity and validation remain application-owned. */
// Mark generated implementations so coverage and architecture checks retain their handwritten-code scope.
@AnnotateWith(lombok.Generated.class)
@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface DomainViewMapper {
@Mapping(target = "dancerIds", source = "dancers")
@Mapping(target = "danceGroupIds", source = "danceGroups")
@Mapping(target = "showIds", source = "shows")
public SchoolView school(final DanceSchool school);
public DancerView dancer(final Dancer dancer);
@Mapping(target = "dancerIds", source = "dancers")
public GroupView group(final DanceGroup group);
@Mapping(target = "dancerId", source = "dancer")
public CoachView coach(final Coach coach);
@Mapping(target = "danceGroupIds", source = "danceGroups")
public DayPartView dayPart(final DayPart part);
/** Violations are calculated by the use case, never inferred by the mapper. */
@Mapping(target = "violations", source = "violations")
@Mapping(target = "preferredDayParts", source = "show", qualifiedByName = "preferredDayParts")
public ShowView show(final Show show, final List<String> violations);
public default Long id(final AbstractEntity entity) {
return entity == null ? null : entity.getId();
}
@Named("preferredDayParts")
public default int preferredDayParts(final Show show) {
return SchedulingInputs.preferred(show);
}
}

View File

@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind {
NOT_FOUND,
STORAGE_UNAVAILABLE,
INVALID_INPUT,
CONFLICT
}

View File

@@ -0,0 +1,54 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RunRepository;
/** Coordinates scheduling status with the same unit of work as domain changes. */
@Singleton
public class SchedulingPersistence {
private final SchoolTransactions transactions;
private final Database database;
private final RunRepository runs;
@Inject
public SchedulingPersistence(
final SchoolTransactions transactions, final Database database, final RunRepository runs) {
this.transactions = transactions;
this.database = database;
this.runs = runs;
}
public List<SchedulingRun> recover() {
return transactions.background(() -> {
runs.recover();
return runs.load();
});
}
public List<SchedulingRun> load() {
return transactions.background(runs::load);
}
public <T> T work(final Callable<T> action) {
return transactions.background(action);
}
public void save(final SchedulingRun run, final Runnable publication) {
runs.save(run);
database.afterCommit(publication);
}
public void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
database.afterRollback(rollback);
save(run, launch);
}
public void result(final long schoolId) {
transactions.saveResult(schoolId);
}
}

View File

@@ -1,23 +1,27 @@
package nl.agilecc.agiledancers.service;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks and publishes current state independently of any HTTP connection. */
/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */
@Startup
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
@@ -25,6 +29,7 @@ public class SchedulingRuns {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Long, SchedulingRun> runs = new HashMap<>();
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
private @Inject SchedulingPersistence persistence;
@Inject
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
@@ -32,74 +37,119 @@ public class SchedulingRuns {
this.engine = engine;
}
public synchronized SchedulingRun start(final long schoolId, final long showId) {
final SchedulingInput input;
final SchedulingRun run;
@PostConstruct
public void recover() {
persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
}
public SchedulingRun start(final long schoolId, final long showId) {
synchronized (schools) {
final String name = schools.show(schoolId, showId).getName();
input = schools.beginScheduling(schoolId, showId);
run = SchedulingRun.started(schoolId, showId, name);
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
final SchedulingRun run = SchedulingRun.started(
schoolId, showId, schools.show(schoolId, showId).getName());
final Runnable launch = () -> launch(run, input);
if (persistence == null) {
launch.run();
} else {
persistence.accepted(run, launch, () -> schools.endScheduling(showId));
}
return run;
}
}
private void launch(final SchedulingRun run, final SchedulingInput input) {
publish(run);
try {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
schools.endScheduling(showId);
publish(run.completed(
finish(run.completed(
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
throw failure;
if (persistence == null) {
throw failure;
}
}
return run;
}
public synchronized SchedulingRun status(final long schoolId, final long showId) {
schools.show(schoolId, showId);
return runs.get(showId);
public SchedulingRun status(final long schoolId, final long showId) {
synchronized (schools) {
schools.show(schoolId, showId);
return runs.get(showId);
}
}
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
public synchronized Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
final Object key = new Object();
listeners.put(key, listener);
listener.accept(List.copyOf(runs.values()));
return () -> unsubscribe(key);
public Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
synchronized (schools) {
if (persistence != null) {
runs.clear();
persistence.load().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
}
final Object key = new Object();
listeners.put(key, listener);
listener.accept(List.copyOf(runs.values()));
return () -> unsubscribe(key);
}
}
private synchronized void unsubscribe(final Object key) {
listeners.remove(key);
private void unsubscribe(final Object key) {
synchronized (schools) {
listeners.remove(key);
}
}
private synchronized void publish(final SchedulingRun run) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
listeners.values().forEach((final Consumer<List<SchedulingRun>> listener) -> listener.accept(snapshot));
private void publish(final SchedulingRun run) {
synchronized (schools) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
for (final Consumer<List<SchedulingRun>> listener : List.copyOf(listeners.values())) {
notifyListener(listener, snapshot);
}
}
}
private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) {
publish(runs.get(run.showId()).progressed(update));
private void notifyListener(final Consumer<List<SchedulingRun>> listener, final List<SchedulingRun> snapshot) {
try {
listener.accept(snapshot);
} catch (final RuntimeException disconnected) {
// A disconnected transport must not turn a committed solver outcome into a failure.
listeners.values().remove(listener);
}
}
private void progress(final SchedulingRun run, final SchedulingProgress update) {
work(() -> {
save(latest(run).progressed(update));
return null;
});
}
private void calculate(final SchedulingRun run, final SchedulingInput input) {
SchedulingRun completed;
try {
final SchedulingResult result =
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
schools.finishScheduling(run.schoolId(), input, result);
completed = latest(run)
.completed(
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
summary);
work(() -> {
schools.finishScheduling(run.schoolId(), input, result);
if (persistence != null) {
persistence.result(run.schoolId());
}
save(latest(run)
.completed(
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
summary));
return null;
});
schools.endScheduling(run.showId());
} catch (final ManagementException failure) {
completed = failed(run, failure);
finish(failed(run, failure));
} catch (final RuntimeException | LinkageError failure) {
completed = latest(run)
.completed(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null);
finish(latest(run)
.completed(
SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null));
}
finish(completed);
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
@@ -115,13 +165,44 @@ public class SchedulingRuns {
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}
private synchronized SchedulingRun latest(final SchedulingRun run) {
return runs.get(run.showId());
private SchedulingRun latest(final SchedulingRun run) {
synchronized (schools) {
return runs.get(run.showId());
}
}
private synchronized void finish(final SchedulingRun completed) {
schools.endScheduling(completed.showId());
publish(completed);
private void finish(final SchedulingRun completed) {
try {
work(() -> {
save(completed);
return null;
});
} finally {
schools.endScheduling(completed.showId());
}
}
private void save(final SchedulingRun run) {
if (persistence == null) {
publish(run);
} else {
persistence.save(run, () -> publish(run));
}
}
private <T> T work(final Callable<T> action) {
synchronized (schools) {
if (persistence != null) {
return persistence.work(action);
}
try {
return action.call();
} catch (final RuntimeException failure) {
throw failure;
} catch (final Exception failure) {
throw new IllegalStateException(failure);
}
}
}
@PreDestroy

View File

@@ -30,7 +30,7 @@ import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */
@Singleton
public class SchoolManagementService {
private final DanceSchoolRepository schools = new DanceSchoolRepository();
@@ -41,6 +41,27 @@ public class SchoolManagementService {
private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>();
/** Installs a detached relational snapshot under the service's existing aggregate lock. */
public synchronized void load(final List<DanceSchool> state, final java.util.function.LongSupplier identifiers) {
revisions.clear();
schools.load(state, identifiers);
dancers.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getDancers().stream())
.toList(),
identifiers);
groups.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getDanceGroups().stream())
.toList(),
identifiers);
shows.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getShows().stream())
.toList(),
identifiers);
}
public synchronized void touch(final Show show) {
show.setFinalized(false);
revisions.merge(show.getId(), 1L, Long::sum);
@@ -64,7 +85,8 @@ public class SchoolManagementService {
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
}
running.add(showId);
return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
return SchedulingInputs.of(
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
}
/** Validates the complete result and input revision before replacing any saved assignments. */
@@ -77,7 +99,7 @@ public class SchoolManagementService {
throw new ManagementException(
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
throw new ManagementException(
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
@@ -320,12 +342,14 @@ public class SchoolManagementService {
snapshot.groups().size(),
snapshot.shows().size(),
existing == null ? null : existing.getId(),
existing == null ? null : existing.getName());
existing == null ? null : existing.getName(),
existing == null ? null : existing.getRevision());
}
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
final DanceSchool staged = snapshot.restore();
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
final ImportPreview preview = preview(snapshot);
if (!Objects.equals(preview.existingId(), replaceId)) {
throw new ManagementException(
@@ -347,6 +371,7 @@ public class SchoolManagementService {
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
final DanceSchool imported = DanceSchool.builder()
.revision(revision)
.key(staged.getKey())
.name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers))

View File

@@ -0,0 +1,118 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RevisionConflictException;
import nl.agilecc.agiledancers.repository.SchoolReader;
import nl.agilecc.agiledancers.repository.SchoolWriter;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.model.SchoolChange;
/** Serializes short domain units of work; solver computation never holds this lock or a database connection. */
@Singleton
public class SchoolTransactions {
private final SchoolManagementService schools;
private final Database database;
private final SchoolReader reader;
private final SchoolWriter writer;
@Inject
public SchoolTransactions(
final SchoolManagementService schools,
final Database database,
final SchoolReader reader,
final SchoolWriter writer) {
this.schools = schools;
this.database = database;
this.reader = reader;
this.writer = writer;
}
/** An expected revision is mandatory for existing-school writes; null school identifies a new-school operation. */
public Object request(final Long schoolId, final Long revision, final boolean mutation, final Callable<Object> work)
throws Exception {
return request(new SchoolChange(schoolId, revision, mutation, mutation), work);
}
public Object request(final SchoolChange change, final Callable<Object> work) throws Exception {
synchronized (schools) {
try {
return database.transaction(() -> {
final List<DanceSchool> before = reader.load();
schools.load(before, database::nextId);
if (change.mutation() && change.schoolId() != null) {
check(change);
schools.school(change.schoolId())
.setRevision(change.revision() + (change.changesInputs() ? 1 : 0));
}
final Object result = work.call();
if (change.changesInputs()) {
store(before, change.schoolId());
}
return result;
});
} catch (final RevisionConflictException failure) {
throw new ManagementException(ManagementExceptionKind.CONFLICT, failure.getMessage());
} catch (final StorageException failure) {
throw new ManagementException(ManagementExceptionKind.STORAGE_UNAVAILABLE, failure.getMessage());
}
}
}
private void check(final SchoolChange change) {
final long schoolId = change.schoolId();
final Long expected = change.revision();
final long actual = schools.school(schoolId).getRevision();
if (expected == null || actual != expected) {
throw new ManagementException(
ManagementExceptionKind.CONFLICT,
"Deze school is gewijzigd of de schoolversie ontbreekt. Herlaad de gegevens en probeer opnieuw.");
}
writer.checkRevision(schoolId, expected);
}
private void store(final List<DanceSchool> before, final Long schoolId) {
final List<DanceSchool> after = schools.schools();
if (schoolId != null) {
final DanceSchool retained = after.stream()
.filter((final DanceSchool school) -> school.getId().equals(schoolId))
.findFirst()
.orElse(null);
if (retained == null) {
writer.delete(schoolId);
} else {
writer.save(retained);
}
}
final List<Long> known = before.stream().map(DanceSchool::getId).toList();
after.stream()
.filter((final DanceSchool school) -> !known.contains(school.getId()))
.forEach(writer::save);
}
/** Refreshes current domain state and commits a worker's atomic result/status operation. */
public <T> T background(final Callable<T> work) {
synchronized (schools) {
try {
return database.transaction(() -> {
schools.load(reader.load(), database::nextId);
return work.call();
});
} catch (final RuntimeException failure) {
throw failure;
} catch (final Exception failure) {
throw new IllegalStateException(failure);
}
}
}
public void saveResult(final long schoolId) {
final DanceSchool school = schools.school(schoolId);
school.setRevision(school.getRevision() + 1);
writer.save(school);
}
}

View File

@@ -1,3 +1,4 @@
package nl.agilecc.agiledancers.service.model;
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public record ImportPreview(
String name, int dancers, int groups, int shows, Long existingId, String existingName, Long existingRevision) {}

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.service.model;
/** Run acceptance checks the loaded revision but does not itself change scheduling inputs. */
public record SchoolChange(Long schoolId, Long revision, boolean mutation, boolean changesInputs) {}

View File

@@ -1,7 +1,7 @@
quarkus.application.name=agiledancers
quarkus.http.host=0.0.0.0
# Scheduling is synchronous with a 30-minute solver limit.
# Keep idle SSE connections alive alongside the 30-minute solver limit.
quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true
@@ -9,3 +9,17 @@ quarkus.jackson.fail-on-unknown-properties=true
%test.quarkus.jacoco.data-file=target/jacoco.exec
%test.quarkus.jacoco.reuse-data-file=true
%test.quarkus.jacoco.report=false
# Production requires an explicitly configured database; initialization failures prevent startup.
quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=${DATABASE_URL}
%prod.quarkus.datasource.username=${DATABASE_USER}
%prod.quarkus.datasource.password=${DATABASE_PASSWORD}
quarkus.datasource.jdbc.acquisition-timeout=5S
quarkus.flyway.migrate-at-start=true
quarkus.datasource.devservices.image-name=postgres:17.6
# Flyway owns DDL; Hibernate validates its JPA mappings against the migrated schema.
quarkus.hibernate-orm.schema-management.strategy=validate
quarkus.hibernate-orm.request-scoped.enabled=false
quarkus.datasource.jdbc.transaction-isolation-level=repeatable-read

View File

@@ -0,0 +1,71 @@
CREATE SEQUENCE entity_id START 1;
CREATE TABLE school (
id bigint PRIMARY KEY, school_key uuid NOT NULL UNIQUE, name text NOT NULL,
revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0)
);
CREATE TABLE dancer (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, birth_date date NOT NULL,
PRIMARY KEY (school_id, id)
);
CREATE TABLE coach (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, dancer_id bigint,
PRIMARY KEY (school_id, id),
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE dance_group (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, category text NOT NULL, group_type text NOT NULL,
start_age integer NOT NULL CHECK (start_age >= 0), end_age integer NOT NULL CHECK (end_age >= start_age),
coach_id bigint, PRIMARY KEY (school_id, id),
FOREIGN KEY (school_id, coach_id) REFERENCES coach DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE group_style (
school_id bigint NOT NULL, group_id bigint NOT NULL, style text NOT NULL,
PRIMARY KEY (school_id, group_id, style),
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE
);
CREATE TABLE group_dancer (
school_id bigint NOT NULL, group_id bigint NOT NULL, dancer_id bigint NOT NULL,
PRIMARY KEY (school_id, group_id, dancer_id),
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE,
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE show (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, preferred_day_parts integer NOT NULL CHECK (preferred_day_parts BETWEEN 1 AND 6),
finalized boolean NOT NULL, PRIMARY KEY (school_id, id)
);
CREATE TABLE show_appearance (
school_id bigint NOT NULL, show_id bigint NOT NULL, group_type text NOT NULL, appearances integer NOT NULL CHECK (appearances >= 0),
PRIMARY KEY (school_id, show_id, group_type),
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
);
CREATE TABLE day_part (
school_id bigint NOT NULL, show_id bigint NOT NULL, position integer NOT NULL CHECK (position >= 0),
day date NOT NULL, part_type text NOT NULL,
PRIMARY KEY (school_id, show_id, position), UNIQUE (school_id, show_id, day, part_type),
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
);
CREATE TABLE assignment (
school_id bigint NOT NULL, show_id bigint NOT NULL, part_position integer NOT NULL,
position integer NOT NULL CHECK (position >= 0), group_id bigint NOT NULL,
PRIMARY KEY (school_id, show_id, part_position, position),
FOREIGN KEY (school_id, show_id, part_position) REFERENCES day_part ON DELETE CASCADE,
FOREIGN KEY (school_id, group_id) REFERENCES dance_group DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE scheduling_run (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
show_id bigint NOT NULL, run_id uuid NOT NULL UNIQUE, show_name text NOT NULL,
started_at timestamptz NOT NULL, finished_at timestamptz,
state text NOT NULL CHECK (state IN ('RUNNING','OPTIMAL','VALID','STALE','INVALID_INPUT','INFEASIBLE','NO_SOLUTION','SOLVER_FAILED','INTERRUPTED')),
phase text NOT NULL CHECK (phase IN ('PREPARING','ATTENDANCE','SPREAD','VARIETY')),
feasible boolean NOT NULL, message text NOT NULL,
attendance_excess bigint, performance_spread integer, variety_penalty bigint,
PRIMARY KEY (school_id, show_id),
FOREIGN KEY (school_id, show_id) REFERENCES show DEFERRABLE INITIALLY DEFERRED,
CHECK ((state = 'RUNNING') = (finished_at IS NULL)),
CHECK ((attendance_excess IS NULL AND performance_spread IS NULL AND variety_penalty IS NULL)
OR (attendance_excess IS NOT NULL AND performance_spread IS NOT NULL AND variety_penalty IS NOT NULL))
);

View File

@@ -0,0 +1,15 @@
-- Child-only edits must dirty the school entity so @Version advances exactly once.
ALTER TABLE school ADD COLUMN change_stamp bigint NOT NULL DEFAULT 0;
-- Keep existing identifiers and start above every value already allocated by V1.
-- JPA allocates identifiers under a row lock, including when a transaction creates a whole import graph.
CREATE TABLE entity_identifiers (
id integer PRIMARY KEY CHECK (id = 1),
next_value bigint NOT NULL CHECK (next_value > 0)
);
INSERT INTO entity_identifiers (id, next_value) VALUES (1, nextval('entity_id'));
-- Hibernate updates retained positions in place; date/type swaps must be checked on the final graph.
ALTER TABLE day_part DROP CONSTRAINT day_part_school_id_show_id_day_part_type_key;
ALTER TABLE day_part ADD CONSTRAINT day_part_date_type_unique
UNIQUE (school_id, show_id, day, part_type) DEFERRABLE INITIALLY DEFERRED;

View File

@@ -0,0 +1,119 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import org.junit.jupiter.api.Test;
@QuarkusTest
class DomainViewMapperTest {
private @Inject DomainViewMapper views;
public @Test void relationshipsBecomeOrderedIdsAndUnlinkedCoachesRetainNull() {
final DanceSchool school =
DanceSchool.builder().name("School").revision(4).build();
school.setId(7L);
final Dancer first = dancer(9L);
final Dancer second = dancer(3L);
school.addDancer(first);
school.addDancer(second);
final SchoolView mapped = views.school(school);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertEquals(4, mapped.revision());
final Coach coach = Coach.builder().name("Coach").dancer(first).build();
coach.setId(2L);
assertEquals(9L, views.coach(coach).dancerId());
coach.setDancer(null);
assertNull(views.coach(coach).dancerId());
school.removeDancer(first);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertThrows(
UnsupportedOperationException.class, () -> mapped.dancerIds().clear());
}
public @Test void nestedViewsKeepAssignmentOrderAndExplicitValidationWithoutSharingCollections() {
final DanceGroup first = DanceGroup.builder().category("First").build();
first.setId(8L);
final DanceGroup second = DanceGroup.builder().category("Second").build();
second.setId(2L);
final List<DanceGroup> assigned = new ArrayList<>(List.of(first, second));
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(assigned)
.build();
final List<String> violations = new ArrayList<>(List.of("Draft validation"));
final Show show = Show.builder()
.name("Show")
.dayParts(new ArrayList<>(List.of(part)))
.appearances(Map.of())
.build();
show.setId(1L);
final ShowView mapped = views.show(show, violations);
assertEquals(1, mapped.preferredDayParts());
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assigned.clear();
violations.clear();
show.getDayParts().clear();
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assertEquals(List.of("Draft validation"), mapped.violations());
assertThrows(
UnsupportedOperationException.class,
() -> mapped.dayParts().getFirst().danceGroupIds().clear());
}
public @Test void preferredDefaultsUseTheExistingRuleAndStylesAreSnapshotValues() {
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build();
final Show show = Show.builder()
.dayParts(List.of(part, part))
.appearances(Map.of())
.build();
show.setId(1L);
assertEquals(2, views.show(show, List.of()).preferredDayParts());
show.setPreferredDayParts(1);
assertEquals(1, views.show(show, List.of()).preferredDayParts());
final DanceGroup group = DanceGroup.builder().category("Group").build();
group.setId(1L);
group.addStyle(DanceGroupStyle.JAZZ);
final Dancer dancer = dancer(4L);
group.addDancer(dancer);
final GroupView mapped = views.group(group);
group.replaceStyles(Set.of(DanceGroupStyle.MODERN));
group.removeDancer(dancer);
assertEquals(Set.of(DanceGroupStyle.JAZZ), mapped.styles());
assertEquals(List.of(4L), mapped.dancerIds());
}
private static Dancer dancer(final long id) {
final Dancer dancer = Dancer.builder()
.name("Dancer")
.birthDate(LocalDate.of(2000, 1, 1))
.build();
dancer.setId(id);
return dancer;
}
}

View File

@@ -0,0 +1,386 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RunRepository;
import nl.agilecc.agiledancers.repository.SchoolReader;
import nl.agilecc.agiledancers.repository.SchoolWriter;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.SchoolTransactions;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class PersistenceTest {
private @Inject SchoolTransactions transactions;
private @Inject SchoolManagementService schools;
private @Inject Database database;
private @Inject SchoolReader reader;
private @Inject SchoolWriter writer;
private @Inject RunRepository runs;
private @Inject jakarta.persistence.EntityManager entities;
public @Test void simultaneousEditsRequireTheLoadedRevision() throws Exception {
final long id = create();
final String path = "/api/dance-schools/" + id;
try (final java.util.concurrent.ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
final java.util.concurrent.Future<Integer> first = executor.submit(() -> rename(path, "First", 0));
final java.util.concurrent.Future<Integer> second = executor.submit(() -> rename(path, "Second", 0));
final List<Integer> statuses = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS));
assertTrue(statuses.containsAll(List.of(200, 409)));
given().contentType("application/json")
.body("{\"name\":\"Missing revision\"}")
.put(path)
.then()
.statusCode(409);
given().get(path + "/data").then().statusCode(200);
assertEquals(1, given().get(path).jsonPath().getInt("revision"));
} finally {
delete(id);
}
}
public @Test void detachedRelationalGraphRetainsIdsReferencesDraftAndFinalState() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
transactions.background(() -> {
final DanceSchool loaded = schools.school(id);
assertEquals(1, loaded.getDancers().size());
assertEquals(
Set.of(DanceGroupStyle.JAZZ),
loaded.getDanceGroups().iterator().next().getStyles());
assertEquals(show.getId(), loaded.getShows().iterator().next().getId());
assertTrue(loaded.getShows().iterator().next().isFinalized());
assertEquals(
loaded.getDanceGroups().iterator().next(),
loaded.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst());
return null;
});
given().get("/api/dance-schools/" + id + "/export").then().statusCode(200);
} finally {
delete(id);
}
}
public @Test void deferredConstraintFailureRollsBackDomainAndCompletedStatusTogether() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName());
database.transaction(() -> {
runs.save(run);
return null;
});
final java.util.concurrent.atomic.AtomicBoolean published = new java.util.concurrent.atomic.AtomicBoolean();
assertThrows(StorageException.class, () -> damageResult(id, show, run, published));
assertFalse(published.get());
database.transaction(() -> {
assertEquals(
"Persistent school",
reader.load().stream()
.filter((final DanceSchool school) -> school.getId() == id)
.findFirst()
.orElseThrow()
.getName());
assertEquals(
1,
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.size());
assertEquals(
SchedulingState.RUNNING,
runs.load().stream()
.filter((final SchedulingRun item) ->
item.runId().equals(run.runId()))
.findFirst()
.orElseThrow()
.state());
return null;
});
} finally {
delete(id);
}
}
public @Test void recoveryRetainsRunIdentityProgressAndExistingAssignments() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName())
.progressed(new SchedulingProgress(SchedulingPhase.SPREAD, true));
database.transaction(() -> {
runs.save(run);
return null;
});
database.transaction(() -> {
runs.recover();
return null;
});
database.transaction(() -> {
final SchedulingRun recovered = runs.load().stream()
.filter((final SchedulingRun item) -> item.runId().equals(run.runId()))
.findFirst()
.orElseThrow();
assertEquals(SchedulingState.INTERRUPTED, recovered.state());
assertEquals(run.progress(), recovered.progress());
assertTrue(recovered.finishedAt() != null);
assertTrue(recovered.message().contains("behouden"));
return null;
});
} finally {
delete(id);
}
}
public @Test void aFailedReplacementImportRetainsThePreviousGraphAndRevision() throws Exception {
final long id = create();
try {
fixture(id);
final nl.agilecc.agiledancers.service.model.SchoolSnapshot before =
transactions.background(() -> schools.exportSchool(id));
assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.request(id, 1L, true, () -> {
final DanceSchool imported = schools.importSchool(before, id);
imported.getDancers().iterator().next().setBirthDate(null);
return imported;
}));
transactions.background(() -> {
assertEquals(before, schools.exportSchool(id));
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void changedSchoolInputsRejectAnOlderSolverSnapshot() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final nl.agilecc.agiledancers.model.scheduling.SchedulingInput input =
transactions.background(() -> schools.beginScheduling(id, show.getId()));
transactions.request(id, 1L, true, () -> schools.updateSchool(id, "Changed"));
final nl.agilecc.agiledancers.service.ManagementException failure = assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.background(() -> {
schools.finishScheduling(
id,
input,
new nl.agilecc.agiledancers.model.scheduling.SchedulingResult(
true, List.of(List.of())));
return null;
}));
assertEquals(nl.agilecc.agiledancers.service.ManagementExceptionKind.CONFLICT, failure.getKind());
transactions.background(() -> {
assertEquals(
1,
schools.show(id, show.getId())
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
schools.endScheduling(show.getId());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateVersionRejectsDetachedStaleEntities() throws Exception {
final long id = create();
try {
final nl.agilecc.agiledancers.repository.entity.SchoolEntity stale = database.transaction(
() -> entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id));
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Confirmed");
return null;
});
stale.setName("Stale");
assertThrows(
nl.agilecc.agiledancers.repository.RevisionConflictException.class,
() -> database.transaction(() -> entities.merge(stale)));
transactions.background(() -> {
assertEquals("Confirmed", schools.school(id).getName());
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void childOnlyEditsAdvanceTheAggregateVersionOnce() throws Exception {
final long id = create();
try {
fixture(id);
transactions.request(id, 1L, true, () -> {
final long dancerId = schools.dancers(id).getFirst().getId();
return schools.updateDancer(id, dancerId, "Updated dancer", LocalDate.of(2000, 1, 1));
});
transactions.background(() -> {
assertEquals(2, schools.school(id).getRevision());
assertEquals("Updated dancer", schools.dancers(id).getFirst().getName());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateRetainsAssignmentsWhenDayPartsAreReordered() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final DayPart added = DayPart.builder()
.date(LocalDate.of(2027, 1, 2))
.type(DayPartType.EVENING)
.danceGroups(List.of())
.build();
transactions.request(
id,
1L,
true,
() -> schools.updateShow(
id,
show.getId(),
"Two parts",
List.of(show.getDayParts().getFirst(), added),
null));
transactions.request(id, 2L, true, () -> {
final Show current = schools.show(id, show.getId());
return schools.updateShow(
id,
show.getId(),
"Swapped",
List.of(
current.getDayParts().getLast(),
current.getDayParts().getFirst()),
null);
});
transactions.background(() -> {
final Show restored = schools.show(id, show.getId());
assertEquals(added.getDate(), restored.getDayParts().getFirst().getDate());
assertEquals(
1, restored.getDayParts().getLast().getDanceGroups().size());
assertEquals(3, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
private void damageResult(
final long id,
final Show show,
final SchedulingRun run,
final java.util.concurrent.atomic.AtomicBoolean published)
throws Exception {
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Uncommitted");
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.clear();
runs.save(run.completed(SchedulingState.OPTIMAL, "Uncommitted", new SchedulingSummary(0, 0, 0)));
database.afterCommit(() -> published.set(true));
// This dancer FK is deferred: work returns normally, and commit itself must fail.
entities.find(
nl.agilecc.agiledancers.repository.entity.GroupEntity.class,
new nl.agilecc.agiledancers.repository.entity.SchoolMemberId(
id,
show.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst()
.getId()))
.getDancerIds()
.add(999999L);
return null;
});
}
private long create() throws Exception {
return (Long) transactions.request(
null,
null,
true,
() -> schools.createSchool("Persistent school").getId());
}
private Show fixture(final long id) throws Exception {
return (Show) transactions.request(id, 0L, true, () -> {
final long dancer =
schools.createDancer(id, "Dancer", LocalDate.of(2000, 1, 1)).getId();
final long group = schools.createGroup(
id, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of(DanceGroupStyle.JAZZ)))
.getId();
schools.addGroupDancer(id, group, dancer);
final Show show = schools.createShow(
id,
"Saved show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of(schools.group(id, group)))
.build()),
null);
show.setFinalized(true);
return show;
});
}
private int rename(final String path, final String name, final long revision) {
return given().header("X-School-Revision", revision)
.contentType("application/json")
.body(java.util.Map.of("name", name))
.put(path)
.statusCode();
}
private void delete(final long id) {
transactions.background(() -> {
writer.delete(id);
return null;
});
}
}

View File

@@ -0,0 +1,50 @@
package nl.agilecc.agiledancers;
import io.restassured.RestAssured;
import io.restassured.response.Response;
import io.restassured.specification.FilterableRequestSpecification;
import io.restassured.specification.RequestSpecification;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Existing endpoint scenarios use fresh revisions; concurrency tests deliberately send explicit stale revisions. */
public class RevisionRequests {
private static final Pattern SCHOOL = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
private static final Pattern REPLACEMENT = Pattern.compile("[?&]replaceId=(\\d+)");
public static RequestSpecification given() {
return RestAssured.given()
.filter(
(final FilterableRequestSpecification request,
final io.restassured.specification.FilterableResponseSpecification response,
final io.restassured.filter.FilterContext context) -> {
addRevision(request);
return context.next(request, response);
});
}
private static void addRevision(final FilterableRequestSpecification request) {
final String id = schoolId(request);
if (!request.getMethod().equals("GET")
&& id != null
&& !request.getHeaders().hasHeaderWithName("X-School-Revision")) {
final Response school = RestAssured.given().get("/api/dance-schools/" + id);
if (school.statusCode() == 200) {
request.header("X-School-Revision", school.jsonPath().getLong("revision"));
}
}
}
private static String schoolId(final FilterableRequestSpecification request) {
final Matcher school = SCHOOL.matcher(request.getURI());
if (school.find()) {
return school.group(1);
}
final Matcher replacement = REPLACEMENT.matcher(request.getURI());
if (replacement.find()) {
return replacement.group(1);
}
final Object parameter = request.getQueryParams().get("replaceId");
return parameter == null ? null : parameter.toString();
}
}

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -36,12 +36,14 @@ import org.junit.jupiter.api.Test;
class SchedulingEventsTest {
private @Inject SchoolManagementService schools;
private @Inject ObjectMapper json;
private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions;
private @TestHTTPResource URI base;
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
final long school = schools.createSchool("SSE school").getId();
final long school = (Long) transactions.request(
null, null, true, () -> schools.createSchool("SSE school").getId());
try {
final Show show = show(school);
final Show show = (Show) transactions.request(school, 0L, true, () -> show(school));
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
given().get(path).then().statusCode(204);
try (final HttpClient client = HttpClient.newHttpClient();
@@ -64,7 +66,7 @@ class SchedulingEventsTest {
}
assertReconnectContains(show.getId());
} finally {
schools.deleteSchool(school);
given().delete("/api/dance-schools/" + school);
}
}

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.equalTo;

View File

@@ -19,11 +19,11 @@ import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;