22
33import java .time .Duration ;
44import java .time .ZonedDateTime ;
5- import java .util .ArrayList ;
65import java .util .Comparator ;
76import java .util .HashSet ;
8- import java .util .List ;
7+ import java .util .LinkedHashSet ;
98import java .util .Objects ;
109import java .util .Optional ;
1110import java .util .Set ;
2423import jakarta .persistence .InheritanceType ;
2524import jakarta .persistence .ManyToOne ;
2625import jakarta .persistence .OneToMany ;
27- import jakarta .persistence .OrderColumn ;
26+ import jakarta .persistence .OrderBy ;
2827import jakarta .persistence .Table ;
2928
3029import org .hibernate .annotations .ConcreteProxy ;
31- import org .jspecify .annotations .NonNull ;
3230import org .jspecify .annotations .Nullable ;
3331
3432import com .fasterxml .jackson .annotation .JsonIgnore ;
@@ -87,13 +85,26 @@ public abstract class Submission extends DomainObject implements Comparable<Subm
8785 @ OneToMany (mappedBy = "submission" , cascade = CascadeType .REMOVE )
8886 private Set <SubmissionVersion > versions = new HashSet <>();
8987
88+ /**
89+ * Orders results by their id, putting a result that was not saved yet at the end: it has just been created, so it
90+ * is the newest one. Without the null handling every lookup here would throw for an in-memory result.
91+ */
92+ private static final Comparator <Result > BY_ID = Comparator .comparing (Result ::getId , Comparator .nullsLast (Comparator .naturalOrder ()));
93+
9094 /**
9195 * A submission can have multiple results, therefore, results are persisted and removed with a submission.
96+ * <p>
97+ * A set, not a list: this used to be an ordered list whose position carried the correction round, which meant every
98+ * write in this area had to load and re-save the whole submission so that Hibernate could renumber the order
99+ * column. The correction round now lives on {@link Result#getCorrectionRound()}, so nothing needs the position.
100+ * <p>
101+ * Ordered by id all the same. Nothing on the server depends on it, but the collection is serialized to the client,
102+ * and a hash set would hand it the results in an order that can change between two requests for the same data.
92103 */
93104 @ OneToMany (mappedBy = "submission" , fetch = FetchType .LAZY , cascade = CascadeType .ALL , orphanRemoval = true )
94- @ OrderColumn ( name = "results_order " )
105+ @ OrderBy ( "id " )
95106 @ JsonIgnoreProperties ({ "submission" , "participation" })
96- private List <Result > results = new ArrayList <>();
107+ private Set <Result > results = new LinkedHashSet <>();
97108
98109 @ Column (name = "submission_date" )
99110 private ZonedDateTime submissionDate ;
@@ -127,7 +138,7 @@ public Long getDurationInMinutes() {
127138 @ Nullable
128139 @ JsonIgnore
129140 public Result getLatestResult () {
130- Result latestResult = Optional .ofNullable (results ).orElse (List .of ()).stream ().filter (Objects ::nonNull ).max (Comparator . comparing ( Result :: getId ) ).orElse (null );
141+ Result latestResult = Optional .ofNullable (results ).orElse (Set .of ()).stream ().filter (Objects ::nonNull ).max (BY_ID ).orElse (null );
131142
132143 if (latestResult != null ) {
133144 latestResult .setSubmission (this );
@@ -146,8 +157,8 @@ public Result getLatestResult() {
146157 @ Nullable
147158 @ JsonIgnore
148159 public Result getLatestCompletedResult () {
149- Result latestResult = Optional .ofNullable (results ).orElse (List .of ()).stream ().filter (result -> result != null && result .getCompletionDate () != null )
150- .max (Comparator .comparing (Result ::getCompletionDate )).orElse (null );
160+ Result latestResult = Optional .ofNullable (results ).orElse (Set .of ()).stream ().filter (result -> result != null && result .getCompletionDate () != null )
161+ .max (Comparator .comparing (Result ::getCompletionDate ). thenComparing ( BY_ID ) ).orElse (null );
151162
152163 if (latestResult != null ) {
153164 latestResult .setSubmission (this );
@@ -166,19 +177,12 @@ public Result getLatestCompletedResult() {
166177 @ Nullable
167178 @ JsonIgnore
168179 public Result getResultForCorrectionRound (int correctionRound ) {
169- List <Result > filteredResults = filterNonAutomaticResults ();
170- if (correctionRound >= 0 && filteredResults .size () > correctionRound ) {
171- return filteredResults .get (correctionRound );
180+ if (correctionRound < 0 ) {
181+ return null ;
172182 }
173- return null ;
174- }
175-
176- /**
177- * @return an unmodifiable list or all non-automatic results
178- */
179- @ NonNull
180- private List <Result > filterNonAutomaticResults () {
181- return results .stream ().filter (result -> result == null || !(result .isAutomatic () || result .isAthenaBased ())).toList ();
183+ // The lowest id among the matches, so that the answer does not depend on the iteration order of an unordered
184+ // set. There should only ever be one result per round, and picking the earliest is what the ordered list did.
185+ return getManualResults ().stream ().filter (result -> Objects .equals (result .getCorrectionRound (), correctionRound )).min (BY_ID ).orElse (null );
182186 }
183187
184188 /**
@@ -191,11 +195,7 @@ private List<Result> filterNonAutomaticResults() {
191195 */
192196 @ JsonIgnore
193197 public boolean hasResultForCorrectionRound (int correctionRound ) {
194- List <Result > withoutAutomaticResults = filterNonAutomaticResults ();
195- if (withoutAutomaticResults .size () > correctionRound ) {
196- return withoutAutomaticResults .get (correctionRound ) != null ;
197- }
198- return false ;
198+ return getResultForCorrectionRound (correctionRound ) != null ;
199199 }
200200
201201 /**
@@ -204,7 +204,8 @@ public boolean hasResultForCorrectionRound(int correctionRound) {
204204 */
205205 @ JsonIgnore
206206 public void removeAutomaticResults () {
207- this .results = this .results .stream ().filter (result -> result == null || !(result .isAutomatic () || result .isAthenaBased ())).collect (Collectors .toCollection (ArrayList ::new ));
207+ this .results = this .results .stream ().filter (result -> result == null || !(result .isAutomatic () || result .isAthenaBased ()))
208+ .collect (Collectors .toCollection (LinkedHashSet ::new ));
208209 }
209210
210211 /**
@@ -219,22 +220,22 @@ public void removeAutomaticResults() {
219220 */
220221 @ JsonIgnore
221222 public void removeNullResults () {
222- this .results = this .results .stream ().filter (Objects ::nonNull ).collect (Collectors .toCollection (ArrayList ::new ));
223+ this .results = this .results .stream ().filter (Objects ::nonNull ).collect (Collectors .toCollection (LinkedHashSet ::new ));
223224 }
224225
225226 @ JsonProperty (value = "results" , access = JsonProperty .Access .READ_ONLY )
226- public List <Result > getResults () {
227+ public Set <Result > getResults () {
227228 return results ;
228229 }
229230
230231 @ JsonIgnore
231- public List <Result > getAutomaticResults () {
232- return results .stream ().filter (result -> result != null && (result .isAutomatic () || result .isAthenaBased ())).collect (Collectors .toCollection (ArrayList ::new ));
232+ public Set <Result > getAutomaticResults () {
233+ return results .stream ().filter (result -> result != null && (result .isAutomatic () || result .isAthenaBased ())).collect (Collectors .toCollection (LinkedHashSet ::new ));
233234 }
234235
235236 @ JsonIgnore
236- public List <Result > getManualResults () {
237- return results .stream ().filter (result -> result != null && !result .isAutomatic () && !result .isAthenaBased ()).collect (Collectors .toCollection (ArrayList ::new ));
237+ public Set <Result > getManualResults () {
238+ return results .stream ().filter (result -> result != null && !result .isAutomatic () && !result .isAthenaBased ()).collect (Collectors .toCollection (LinkedHashSet ::new ));
238239 }
239240
240241 /**
@@ -243,8 +244,8 @@ public List<Result> getManualResults() {
243244 * @return non athena automatic results excluding null results
244245 */
245246 @ JsonIgnore
246- public List <Result > getNonAthenaResults () {
247- return results .stream ().filter (result -> result != null && !result .isAthenaBased ()).collect (Collectors .toCollection (ArrayList ::new ));
247+ public Set <Result > getNonAthenaResults () {
248+ return results .stream ().filter (result -> result != null && !result .isAthenaBased ()).collect (Collectors .toCollection (LinkedHashSet ::new ));
248249 }
249250
250251 /**
@@ -267,10 +268,10 @@ public Result getManualResultsById(long resultId) {
267268 @ Nullable
268269 @ JsonIgnore
269270 public Result getFirstResult () {
270- if (results != null && ! results .isEmpty ()) {
271- return results . getFirst () ;
271+ if (results == null || results .isEmpty ()) {
272+ return null ;
272273 }
273- return null ;
274+ return results . stream (). filter ( Objects :: nonNull ). min ( BY_ID ). orElse ( null ) ;
274275 }
275276
276277 /**
@@ -281,10 +282,12 @@ public Result getFirstResult() {
281282 @ Nullable
282283 @ JsonIgnore
283284 public Result getFirstManualResult () {
284- // Guard on the manual results, not on all results: a submission can carry only automatic or Athena results, and
285- // getFirst() on the then empty manual list would throw instead of returning null as declared.
286- List <Result > manualResults = results == null ? List .of () : getManualResults ();
287- return manualResults .isEmpty () ? null : manualResults .getFirst ();
285+ // The earliest manual result, which is the one of the first correction round. Guard on the manual results, not
286+ // on all results: a submission can carry only automatic or Athena results and then there is none.
287+ if (results == null ) {
288+ return null ;
289+ }
290+ return getManualResults ().stream ().min (BY_ID ).orElse (null );
288291 }
289292
290293 /**
@@ -299,29 +302,49 @@ public Result getFirstManualResult() {
299302 @ Nullable
300303 @ JsonIgnore
301304 public Result getLatestManualResult () {
302- List <Result > manualResults = results == null ? List .of () : getManualResults ();
303- return manualResults .isEmpty () ? null : manualResults .getLast ();
305+ // The most recent manual result, which is the one of the highest correction round.
306+ if (results == null ) {
307+ return null ;
308+ }
309+ return getManualResults ().stream ().max (BY_ID ).orElse (null );
304310 }
305311
306312 /**
307- * Add a result to the list.
308- * NOTE: You must make sure to correctly persist the result in the database!
313+ * Adds a result to this submission and, if it is a correction-round result that does not have a round yet, assigns
314+ * the next one.
315+ * <p>
316+ * This is where the round used to come from implicitly: the results were an ordered list and the position carried
317+ * the round, so adding a result to the list decided which round it belonged to. The round now lives on the result,
318+ * and this is the same moment, so the behaviour is unchanged for every caller that does not set it itself.
319+ * {@code SubmissionService.lockSubmission} does set it, from the round the tutor asked for, and that takes
320+ * precedence. Automatic and Athena results are not correction rounds and keep no round.
309321 *
310- * @param result the {@link Result} which should be added
322+ * @param result the result to add
311323 */
312324 public void addResult (Result result ) {
325+ if (result != null && result .getCorrectionRound () == null && !result .isAutomatic () && !result .isAthenaBased ()) {
326+ result .setCorrectionRound (countCorrectionRoundResults (result ));
327+ }
313328 this .results .add (result );
314329 }
315330
331+ /**
332+ * @param resultToAdd the result that is about to be added, which must not count itself
333+ * @return how many correction-round results this submission already holds
334+ */
335+ private int countCorrectionRoundResults (Result resultToAdd ) {
336+ return (int ) results .stream ().filter (other -> other != null && other != resultToAdd && !other .isAutomatic () && !other .isAthenaBased ()).count ();
337+ }
338+
316339 /**
317340 * Set the results list to the specified list.
318341 * NOTE: You must correctly persist this change in the database manually!
319342 *
320343 * @param results The list of {@link Result} which should replace the existing results of the submission
321344 */
322345 @ JsonProperty (value = "results" , access = JsonProperty .Access .WRITE_ONLY )
323- public void setResults (List <Result > results ) {
324- this .results = results ;
346+ public void setResults (Set <Result > results ) {
347+ this .results = results != null ? results : new LinkedHashSet <>() ;
325348 }
326349
327350 public Participation getParticipation () {
@@ -398,9 +421,12 @@ public void setExampleSubmission(Boolean exampleSubmission) {
398421 */
399422 public void removeNotNeededResults (int correctionRound , Long resultId ) {
400423 if (correctionRound == 0 && resultId == null && getResults ().size () >= 2 ) {
401- var resultList = new ArrayList <Result >();
402- resultList .add (getFirstManualResult ());
403- setResults (resultList );
424+ var remainingResults = new HashSet <Result >();
425+ var firstManualResult = getFirstManualResult ();
426+ if (firstManualResult != null ) {
427+ remainingResults .add (firstManualResult );
428+ }
429+ setResults (remainingResults );
404430 }
405431 }
406432
0 commit comments