Skip to content

Commit 4ed9460

Browse files
authored
Merge pull request opentripplanner#7350 from entur/parse-not-validate-callwrapper
Refactor CallWrapper to parse-not-validate pattern
2 parents 760c210 + 8a0b7c5 commit 4ed9460

10 files changed

Lines changed: 341 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: 78 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,99 @@
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+
// we reject messages that contain both Order and VisitNumber since we do not see any obvious
75+
// use case that requires both, and making them mutually exclusive make the implementation
76+
// simpler. We can relax this validation rule later if valid use cases are identified.
77+
if (hasOrderCalls && hasVisitNumberCalls) {
78+
return Result.failure(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
79+
}
4880

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();
81+
result.sort(Comparator.comparingInt(CallWrapper::getSortOrder));
82+
83+
return Result.success(List.copyOf(result));
6984
}
7085

7186
/**
72-
* Validate that the call has a non-empty stop point ref and either order or visit number (but not
73-
* both).
87+
* Validate a single call's stop point ref and resolve its sort order from Order/VisitNumber.
7488
*/
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();
89+
private static Result<Integer, UpdateErrorType> validateCall(
90+
StopPointRefStructure stopPointRef,
91+
java.math.BigInteger order,
92+
java.math.BigInteger visitNumber
93+
) {
94+
var ref = stopPointRef != null ? stopPointRef.getValue() : null;
95+
if (StringUtils.hasNoValueOrNullAsString(ref)) {
96+
return Result.failure(UpdateErrorType.EMPTY_STOP_POINT_REF);
97+
}
98+
if (order == null && visitNumber == null) {
99+
return Result.failure(UpdateErrorType.MISSING_CALL_ORDER);
100+
}
101+
if (order != null && visitNumber != null) {
102+
return Result.failure(UpdateErrorType.MIXED_CALL_ORDER_AND_VISIT_NUMBER);
103+
}
104+
return Result.success(order != null ? order.intValueExact() : visitNumber.intValueExact());
86105
}
87106

88107
String getStopPointRef();
89108

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

@@ -128,37 +143,21 @@ default boolean hasDeparted() {
128143
final class EstimatedCallWrapper implements CallWrapper {
129144

130145
private final EstimatedCall call;
146+
private final int sortOrder;
131147

132-
private EstimatedCallWrapper(EstimatedCall estimatedCall) {
148+
private EstimatedCallWrapper(EstimatedCall estimatedCall, int sortOrder) {
133149
this.call = estimatedCall;
150+
this.sortOrder = sortOrder;
134151
}
135152

136153
@Override
137154
public String getStopPointRef() {
138155
return call.getStopPointRef() != null ? call.getStopPointRef().getValue() : null;
139156
}
140157

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-
*/
157158
@Override
158159
public int getSortOrder() {
159-
return call.getOrder() != null
160-
? call.getOrder().intValueExact()
161-
: call.getVisitNumber().intValueExact();
160+
return sortOrder;
162161
}
163162

164163
@Override
@@ -258,31 +257,21 @@ public boolean equals(Object obj) {
258257
final class RecordedCallWrapper implements CallWrapper {
259258

260259
private final RecordedCall call;
260+
private final int sortOrder;
261261

262-
private RecordedCallWrapper(RecordedCall estimatedCall) {
263-
this.call = estimatedCall;
262+
private RecordedCallWrapper(RecordedCall recordedCall, int sortOrder) {
263+
this.call = recordedCall;
264+
this.sortOrder = sortOrder;
264265
}
265266

266267
@Override
267268
public String getStopPointRef() {
268269
return call.getStopPointRef() != null ? call.getStopPointRef().getValue() : null;
269270
}
270271

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-
281272
@Override
282273
public int getSortOrder() {
283-
return call.getOrder() != null
284-
? call.getOrder().intValueExact()
285-
: call.getVisitNumber().intValueExact();
274+
return sortOrder;
286275
}
287276

288277
@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)