Skip to content

Commit 87bbef1

Browse files
committed
Refactor CallWrapper to parse-not-validate pattern
Replace the two-step construct-then-validate pattern with a single factory that returns Result<List<CallWrapper>, UpdateErrorType>. Invalid CallWrapper instances are now unrepresentable: the factory validates stopPointRef, Order/VisitNumber per call, and cross-call consistency during parsing. Sort order is resolved once and stored as a field, and the returned list is sorted by it. The pre-parsed call list is threaded through the entire SIRI-ET pipeline (adapter, builders, fuzzy matcher, entity resolver), eliminating redundant re-wrapping of the raw EVJ calls. Sorting by Order/VisitNumber during parsing also subsumes the fix for out-of-order calls (Nordic SIRI Profile missed-recording exception), ensuring EntityResolver.resolveServiceDate always sees the correct first call.
1 parent 77c5315 commit 87bbef1

10 files changed

Lines changed: 338 additions & 131 deletions

File tree

application/src/main/java/org/opentripplanner/updater/trip/siri/AddedTripBuilder.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ class AddedTripBuilder {
7676
TransitEditorService transitService,
7777
DeduplicatorService deduplicator,
7878
EntityResolver entityResolver,
79-
Function<Trip, FeedScopedId> getTripPatternId
79+
Function<Trip, FeedScopedId> getTripPatternId,
80+
List<CallWrapper> calls
8081
) {
8182
this.deduplicator = deduplicator;
8283
// Verifying values required in SIRI Profile
@@ -104,7 +105,7 @@ class AddedTripBuilder {
104105
: lineRef;
105106
replacedRoute = entityResolver.resolveRoute(externalLineRef);
106107

107-
serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney);
108+
serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney, calls);
108109

109110
shortName = getFirstStringFromList(estimatedVehicleJourney.getPublishedLineNames());
110111

@@ -117,7 +118,7 @@ class AddedTripBuilder {
117118
cancellation = TRUE.equals(estimatedVehicleJourney.isCancellation());
118119
headsign = getFirstStringFromList(estimatedVehicleJourney.getDestinationNames());
119120

120-
calls = CallWrapper.of(estimatedVehicleJourney);
121+
this.calls = calls;
121122

122123
this.transitService = transitService;
123124
this.entityResolver = entityResolver;

application/src/main/java/org/opentripplanner/updater/trip/siri/CallWrapper.java

Lines changed: 75 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import java.time.ZonedDateTime;
44
import java.util.ArrayList;
5+
import java.util.Comparator;
56
import java.util.List;
6-
import java.util.Optional;
7+
import org.opentripplanner.transit.model.framework.Result;
78
import org.opentripplanner.updater.spi.UpdateError.UpdateErrorType;
89
import org.opentripplanner.utils.lang.StringUtils;
910
import uk.org.siri.siri21.ArrivalBoardingActivityEnumeration;
@@ -14,85 +15,96 @@
1415
import uk.org.siri.siri21.NaturalLanguageStringStructure;
1516
import uk.org.siri.siri21.OccupancyEnumeration;
1617
import uk.org.siri.siri21.RecordedCall;
18+
import uk.org.siri.siri21.StopPointRefStructure;
1719

1820
/**
1921
* This class is a wrapper around either a {@link RecordedCall} or an {@link EstimatedCall}, making
2022
* it possible to iterate over both of the types at once.
23+
* <p>
24+
* Instances are created via the {@link #of(EstimatedVehicleJourney)} factory which validates and
25+
* sorts calls during parsing, making invalid {@code CallWrapper} instances unrepresentable.
2126
*/
2227
public interface CallWrapper {
23-
static CallWrapper of(EstimatedCall estimatedCall) {
24-
return new EstimatedCallWrapper(estimatedCall);
25-
}
26-
27-
static CallWrapper of(RecordedCall recordedCall) {
28-
return new RecordedCallWrapper(recordedCall);
29-
}
30-
31-
static List<CallWrapper> of(EstimatedVehicleJourney estimatedVehicleJourney) {
28+
/**
29+
* Parse and validate all calls from an {@link EstimatedVehicleJourney}. Each call must have a
30+
* non-empty stop point ref and exactly one of Order or VisitNumber. All calls must use the same
31+
* strategy (all Order or all VisitNumber). The returned list is sorted by sort order.
32+
*
33+
* @return a successful sorted list of calls, or a failure with the appropriate error type
34+
*/
35+
static Result<List<CallWrapper>, UpdateErrorType> of(
36+
EstimatedVehicleJourney estimatedVehicleJourney
37+
) {
3238
List<CallWrapper> result = new ArrayList<>();
39+
boolean hasOrderCalls = false;
40+
boolean hasVisitNumberCalls = false;
3341

3442
if (estimatedVehicleJourney.getRecordedCalls() != null) {
35-
for (var recordedCall : estimatedVehicleJourney.getRecordedCalls().getRecordedCalls()) {
36-
result.add(new RecordedCallWrapper(recordedCall));
43+
for (var call : estimatedVehicleJourney.getRecordedCalls().getRecordedCalls()) {
44+
var sortOrder = validateCall(
45+
call.getStopPointRef(),
46+
call.getOrder(),
47+
call.getVisitNumber()
48+
);
49+
if (sortOrder.isFailure()) {
50+
return sortOrder.toFailureResult();
51+
}
52+
hasOrderCalls |= call.getOrder() != null;
53+
hasVisitNumberCalls |= call.getVisitNumber() != null;
54+
result.add(new RecordedCallWrapper(call, sortOrder.successValue()));
3755
}
3856
}
3957

4058
if (estimatedVehicleJourney.getEstimatedCalls() != null) {
41-
for (var estimatedCall : estimatedVehicleJourney.getEstimatedCalls().getEstimatedCalls()) {
42-
result.add(new EstimatedCallWrapper(estimatedCall));
59+
for (var call : estimatedVehicleJourney.getEstimatedCalls().getEstimatedCalls()) {
60+
var sortOrder = validateCall(
61+
call.getStopPointRef(),
62+
call.getOrder(),
63+
call.getVisitNumber()
64+
);
65+
if (sortOrder.isFailure()) {
66+
return sortOrder.toFailureResult();
67+
}
68+
hasOrderCalls |= call.getOrder() != null;
69+
hasVisitNumberCalls |= call.getVisitNumber() != null;
70+
result.add(new EstimatedCallWrapper(call, sortOrder.successValue()));
4371
}
4472
}
4573

46-
return List.copyOf(result);
47-
}
74+
if (hasOrderCalls && hasVisitNumberCalls) {
75+
return Result.failure(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
76+
}
4877

49-
/**
50-
* Validate that all calls have a non-empty stop point ref and either order or visit number (but
51-
* not both). Also checks cross-call consistency: all calls must use the same strategy (all Order
52-
* or all VisitNumber). A mix of Order and VisitNumber-only calls is rejected.
53-
*/
54-
static Optional<UpdateErrorType> validateAll(List<CallWrapper> calls) {
55-
var perCallError = calls
56-
.stream()
57-
.map(CallWrapper::validate)
58-
.flatMap(Optional::stream)
59-
.findFirst();
60-
if (perCallError.isPresent()) {
61-
return perCallError;
62-
}
63-
boolean anyHasOrder = calls.stream().anyMatch(CallWrapper::hasOrder);
64-
boolean anyMissingOrder = calls.stream().anyMatch(c -> !c.hasOrder());
65-
if (anyHasOrder && anyMissingOrder) {
66-
return Optional.of(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
67-
}
68-
return Optional.empty();
78+
result.sort(Comparator.comparingInt(CallWrapper::getSortOrder));
79+
80+
return Result.success(List.copyOf(result));
6981
}
7082

7183
/**
72-
* Validate that the call has a non-empty stop point ref and either order or visit number (but not
73-
* both).
84+
* Validate a single call's stop point ref and resolve its sort order from Order/VisitNumber.
7485
*/
75-
default Optional<UpdateErrorType> validate() {
76-
if (StringUtils.hasNoValueOrNullAsString(getStopPointRef())) {
77-
return Optional.of(UpdateErrorType.EMPTY_STOP_POINT_REF);
78-
}
79-
if (!hasOrder() && !hasVisitNumber()) {
80-
return Optional.of(UpdateErrorType.MISSING_CALL_ORDER);
81-
}
82-
if (hasOrder() && hasVisitNumber()) {
83-
return Optional.of(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
84-
}
85-
return Optional.empty();
86+
private static Result<Integer, UpdateErrorType> validateCall(
87+
StopPointRefStructure stopPointRef,
88+
java.math.BigInteger order,
89+
java.math.BigInteger visitNumber
90+
) {
91+
var ref = stopPointRef != null ? stopPointRef.getValue() : null;
92+
if (StringUtils.hasNoValueOrNullAsString(ref)) {
93+
return Result.failure(UpdateErrorType.EMPTY_STOP_POINT_REF);
94+
}
95+
if (order == null && visitNumber == null) {
96+
return Result.failure(UpdateErrorType.MISSING_CALL_ORDER);
97+
}
98+
if (order != null && visitNumber != null) {
99+
return Result.failure(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
100+
}
101+
return Result.success(order != null ? order.intValueExact() : visitNumber.intValueExact());
86102
}
87103

88104
String getStopPointRef();
89105

90-
boolean hasOrder();
91-
92-
boolean hasVisitNumber();
93-
94106
/**
95-
* Return the sort order of this call. Prefers Order if present, falls back to VisitNumber.
107+
* Return the sort order of this call, resolved during parsing from either Order or VisitNumber.
96108
*/
97109
int getSortOrder();
98110

@@ -128,37 +140,21 @@ default boolean hasDeparted() {
128140
final class EstimatedCallWrapper implements CallWrapper {
129141

130142
private final EstimatedCall call;
143+
private final int sortOrder;
131144

132-
private EstimatedCallWrapper(EstimatedCall estimatedCall) {
145+
private EstimatedCallWrapper(EstimatedCall estimatedCall, int sortOrder) {
133146
this.call = estimatedCall;
147+
this.sortOrder = sortOrder;
134148
}
135149

136150
@Override
137151
public String getStopPointRef() {
138152
return call.getStopPointRef() != null ? call.getStopPointRef().getValue() : null;
139153
}
140154

141-
@Override
142-
public boolean hasOrder() {
143-
return call.getOrder() != null;
144-
}
145-
146-
@Override
147-
public boolean hasVisitNumber() {
148-
return call.getVisitNumber() != null;
149-
}
150-
151-
/**
152-
* Return the call order, either from the Order field or the VisitNumber field.
153-
* Validation ensures that one of them is set.
154-
* See {@link #validate()}
155-
* @return
156-
*/
157155
@Override
158156
public int getSortOrder() {
159-
return call.getOrder() != null
160-
? call.getOrder().intValueExact()
161-
: call.getVisitNumber().intValueExact();
157+
return sortOrder;
162158
}
163159

164160
@Override
@@ -258,31 +254,21 @@ public boolean equals(Object obj) {
258254
final class RecordedCallWrapper implements CallWrapper {
259255

260256
private final RecordedCall call;
257+
private final int sortOrder;
261258

262-
private RecordedCallWrapper(RecordedCall estimatedCall) {
263-
this.call = estimatedCall;
259+
private RecordedCallWrapper(RecordedCall recordedCall, int sortOrder) {
260+
this.call = recordedCall;
261+
this.sortOrder = sortOrder;
264262
}
265263

266264
@Override
267265
public String getStopPointRef() {
268266
return call.getStopPointRef() != null ? call.getStopPointRef().getValue() : null;
269267
}
270268

271-
@Override
272-
public boolean hasOrder() {
273-
return call.getOrder() != null;
274-
}
275-
276-
@Override
277-
public boolean hasVisitNumber() {
278-
return call.getVisitNumber() != null;
279-
}
280-
281269
@Override
282270
public int getSortOrder() {
283-
return call.getOrder() != null
284-
? call.getOrder().intValueExact()
285-
: call.getVisitNumber().intValueExact();
271+
return sortOrder;
286272
}
287273

288274
@Override

application/src/main/java/org/opentripplanner/updater/trip/siri/EntityResolver.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.time.LocalDate;
55
import java.time.ZonedDateTime;
66
import java.time.format.DateTimeParseException;
7+
import java.util.List;
78
import javax.annotation.Nullable;
89
import org.opentripplanner.core.model.id.FeedScopedId;
910
import org.opentripplanner.transit.model.network.Route;
@@ -214,7 +215,10 @@ public Operator resolveOperator(String operatorRef) {
214215
}
215216

216217
@Nullable
217-
public LocalDate resolveServiceDate(EstimatedVehicleJourney vehicleJourney) {
218+
public LocalDate resolveServiceDate(
219+
EstimatedVehicleJourney vehicleJourney,
220+
List<CallWrapper> calls
221+
) {
218222
if (vehicleJourney.getFramedVehicleJourneyRef() != null) {
219223
var dataFrame = vehicleJourney.getFramedVehicleJourneyRef().getDataFrameRef();
220224
if (dataFrame != null) {
@@ -234,17 +238,18 @@ public LocalDate resolveServiceDate(EstimatedVehicleJourney vehicleJourney) {
234238
}
235239
}
236240

237-
var datetime = CallWrapper.of(vehicleJourney)
238-
.stream()
239-
.findFirst()
240-
.map(CallWrapper::getAimedDepartureTime);
241-
if (datetime.isEmpty()) {
241+
if (calls.isEmpty()) {
242+
return null;
243+
}
244+
245+
var departureTime = calls.getFirst().getAimedDepartureTime();
246+
if (departureTime == null) {
242247
return null;
243248
}
244249

245250
var daysOffset = calculateDayOffset(vehicleJourney);
246251

247-
return datetime.get().toLocalDate().minusDays(daysOffset);
252+
return departureTime.toLocalDate().minusDays(daysOffset);
248253
}
249254

250255
/**

application/src/main/java/org/opentripplanner/updater/trip/siri/ExtraCallTripBuilder.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,22 @@ class ExtraCallTripBuilder {
5252
DeduplicatorService deduplicator,
5353
EntityResolver entityResolver,
5454
Function<Trip, FeedScopedId> generateTripPatternId,
55-
Trip trip
55+
Trip trip,
56+
List<CallWrapper> calls
5657
) {
5758
this.trip = Objects.requireNonNull(trip);
5859

5960
this.deduplicator = deduplicator;
6061
// DataSource of added trip
6162
dataSource = estimatedVehicleJourney.getDataSource();
6263

63-
serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney);
64+
serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney, calls);
6465

6566
isJourneyPredictionInaccurate = TRUE.equals(estimatedVehicleJourney.isPredictionInaccurate());
6667
occupancy = estimatedVehicleJourney.getOccupancy();
6768
cancellation = TRUE.equals(estimatedVehicleJourney.isCancellation());
6869

69-
calls = CallWrapper.of(estimatedVehicleJourney);
70+
this.calls = calls;
7071

7172
this.transitService = transitService;
7273
this.generateTripPatternId = generateTripPatternId;

application/src/main/java/org/opentripplanner/updater/trip/siri/ModifiedTripBuilder.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,16 @@ public ModifiedTripBuilder(
5555
EstimatedVehicleJourney journey,
5656
LocalDate serviceDate,
5757
ZoneId zoneId,
58-
EntityResolver entityResolver
58+
EntityResolver entityResolver,
59+
List<CallWrapper> calls
5960
) {
6061
this.existingTripTimes = existingTripTimes;
6162
this.pattern = pattern;
6263
this.serviceDate = serviceDate;
6364
this.zoneId = zoneId;
6465
this.entityResolver = entityResolver;
6566

66-
calls = CallWrapper.of(journey);
67+
this.calls = calls;
6768
cancellation = TRUE.equals(journey.isCancellation());
6869
predictionInaccurate = TRUE.equals(journey.isPredictionInaccurate());
6970
occupancy = journey.getOccupancy();

application/src/main/java/org/opentripplanner/updater/trip/siri/SiriFuzzyTripMatcher.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,11 @@ public SiriFuzzyTripMatcher(TransitService transitService) {
6262
*/
6363
public Result<TripAndPattern, UpdateError.UpdateErrorType> match(
6464
EstimatedVehicleJourney journey,
65+
List<CallWrapper> calls,
6566
EntityResolver entityResolver,
6667
BiFunction<TripPattern, LocalDate, Timetable> getCurrentTimetable,
6768
BiFunction<FeedScopedId, LocalDate, TripPattern> getNewTripPatternForModifiedTrip
6869
) {
69-
List<CallWrapper> calls = CallWrapper.of(journey);
70-
7170
if (calls.isEmpty()) {
7271
return Result.failure(NO_VALID_STOPS);
7372
}

0 commit comments

Comments
 (0)