Skip to content

Commit 5b04ba2

Browse files
Merge pull request #482 from JRroony/issue-142-unique-merge-ids
Avoid duplicate service IDs during GTFS merge
2 parents c0ab195 + d08d29c commit 5b04ba2

3 files changed

Lines changed: 359 additions & 4 deletions

File tree

onebusaway-gtfs-merge/src/main/java/org/onebusaway/gtfs_merge/strategies/AbstractCollectionEntityMergeStrategy.java

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package org.onebusaway.gtfs_merge.strategies;
1515

1616
import java.io.Serializable;
17+
import java.util.ArrayList;
1718
import java.util.Collection;
1819
import java.util.HashSet;
1920
import java.util.Set;
@@ -55,8 +56,13 @@ public AbstractCollectionEntityMergeStrategy(String keyDescription) {
5556

5657
@Override
5758
public void merge(GtfsMergeContext context) {
58-
for (KEY key : getKeys(context.getSource())) {
59-
processKey(context, key);
59+
Collection<KEY> sourceKeys = new ArrayList<>(getKeys(context.getSource()));
60+
Set<String> sourceOriginalRawIds = new HashSet<>();
61+
for (KEY key : sourceKeys) {
62+
sourceOriginalRawIds.add(getRawKey(key));
63+
}
64+
for (KEY key : sourceKeys) {
65+
processKey(context, key, sourceOriginalRawIds);
6066
}
6167
}
6268

@@ -66,8 +72,10 @@ public void merge(GtfsMergeContext context) {
6672
*
6773
* @param context
6874
* @param key the identifier of the current entity collection to process
75+
* @param sourceOriginalRawIds all raw ids originally present in the current source feed, reserved
76+
* for the duration of this strategy merge
6977
*/
70-
private void processKey(GtfsMergeContext context, KEY key) {
78+
private void processKey(GtfsMergeContext context, KEY key, Set<String> sourceOriginalRawIds) {
7179
KEY duplicate = getDuplicate(context, key);
7280
if (duplicate != null) {
7381
logDuplicateKey(key);
@@ -83,7 +91,7 @@ private void processKey(GtfsMergeContext context, KEY key) {
8391
* avoid duplication.
8492
*/
8593
if (context.getEntityForRawId(rawKey) != null) {
86-
KEY newKey = getRenamedKey(context, key);
94+
KEY newKey = getNextAvailableRenamedKey(context, key, sourceOriginalRawIds);
8795
renameKey(context, key, newKey);
8896
key = newKey;
8997
rawKey = getRawKey(key);
@@ -94,6 +102,43 @@ private void processKey(GtfsMergeContext context, KEY key) {
94102
saveElementsForKey(context, key);
95103
}
96104

105+
/**
106+
* Finds a renamed id for the specified key that is safe to use: one that doesn't collide with an
107+
* id already present in the merged output feed, and doesn't collide with any raw id originally
108+
* present in the current source feed. Those original ids remain reserved for the duration of this
109+
* strategy merge. Candidates are generated by repeatedly applying {@link
110+
* #getRenamedKey(GtfsMergeContext, Serializable)} without mutating the source feed; the caller is
111+
* responsible for actually applying the rename once a safe candidate is found.
112+
*
113+
* @param context
114+
* @param key the original, colliding key
115+
* @param sourceOriginalRawIds the set of raw ids originally present in the current source feed
116+
* @return a renamed key that is safe to use
117+
*/
118+
private KEY getNextAvailableRenamedKey(
119+
GtfsMergeContext context, KEY key, Set<String> sourceOriginalRawIds) {
120+
String previousRawKey = getRawKey(key);
121+
KEY candidate = key;
122+
while (true) {
123+
KEY renamed = getRenamedKey(context, candidate);
124+
String renamedRawKey = getRawKey(renamed);
125+
if (renamedRawKey.equals(previousRawKey)) {
126+
throw new IllegalStateException(
127+
"renaming key="
128+
+ candidate
129+
+ " produced no change to raw id="
130+
+ renamedRawKey
131+
+ "; check merge prefix configuration");
132+
}
133+
candidate = renamed;
134+
previousRawKey = renamedRawKey;
135+
if (!sourceOriginalRawIds.contains(renamedRawKey)
136+
&& context.getEntityForRawId(renamedRawKey) == null) {
137+
return candidate;
138+
}
139+
}
140+
}
141+
97142
/**
98143
* An entity-specific method to determine the set of unique identifiers used by collection
99144
* entities in the specified GTFS feed.

onebusaway-gtfs-merge/src/test/java/org/onebusaway/gtfs_merge/GtfsMergerTest.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@
1414
package org.onebusaway.gtfs_merge;
1515

1616
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertNotNull;
1718
import static org.junit.jupiter.api.Assertions.assertTrue;
1819

1920
import java.io.File;
2021
import java.io.IOException;
2122
import java.util.ArrayList;
23+
import java.util.HashMap;
24+
import java.util.HashSet;
2225
import java.util.Iterator;
2326
import java.util.List;
27+
import java.util.Map;
28+
import java.util.Set;
2429
import org.junit.jupiter.api.AfterEach;
2530
import org.junit.jupiter.api.BeforeEach;
2631
import org.junit.jupiter.api.Test;
@@ -628,6 +633,107 @@ public void testLocationTypeMismatch_PlatformFeedFirstStationFeedSecond() throws
628633
assertTrue(foundStopTime, "expected at least one merged stop_time");
629634
}
630635

636+
/**
637+
* Reproduces issue 142: after a raw service_id collision forces a rename, the renamed id must not
638+
* collide with an id that already exists in the merged output feed (or in another,
639+
* not-yet-processed key from the same source feed). Round-trips the merged feed through a
640+
* writer/reader cycle to make sure the on-disk result is unambiguous.
641+
*/
642+
@Test
643+
public void testServiceIdRenameAvoidsCollisionWithExistingRawId() throws IOException {
644+
// "fresh" input (lowest priority, processed second): a single, previously-unmerged
645+
// service calendar whose raw id "T0" collides with an id in the higher-priority input.
646+
_oldGtfs.putAgencies(1);
647+
_oldGtfs.putRoutes(1);
648+
_oldGtfs.putStops(3);
649+
_oldGtfs.putCalendars(1, "mask=1111100", "service_id=T0");
650+
_oldGtfs.putCalendarDates("T0=20120704");
651+
_oldGtfs.putTrips(1, "r0", "T0", "trip_id=fresh-trip");
652+
_oldGtfs.putStopTimes("fresh-trip", "s0,s1,s2");
653+
654+
// "already merged" input (highest priority, processed first): already contains both the
655+
// raw id "T0" and a previously-renamed raw id "a-T0" as two logically distinct calendars.
656+
_newGtfs.putAgencies(1);
657+
_newGtfs.putRoutes(1);
658+
_newGtfs.putStops(3);
659+
_newGtfs.putCalendars(2, "mask=0000011,1010101", "service_id=T0,a-T0");
660+
_newGtfs.putCalendarDates("T0=20120705", "a-T0=20120706");
661+
_newGtfs.putTrips(2, "r0,r0", "T0,a-T0", "trip_id=target-t0-trip,target-a-t0-trip");
662+
_newGtfs.putStopTimes("target-t0-trip,target-a-t0-trip", "s0,s1,s2");
663+
664+
ServiceCalendarMergeStrategy strategy = new ServiceCalendarMergeStrategy();
665+
strategy.setDuplicateDetectionStrategy(EDuplicateDetectionStrategy.NONE);
666+
strategy.setDuplicateRenamingStrategy(EDuplicateRenamingStrategy.CONTEXT);
667+
_merger.setServiceCalendarStrategy(strategy);
668+
669+
GtfsRelationalDao dao = merge();
670+
671+
Set<String> serviceIds = new HashSet<>();
672+
Map<String, String> serviceIdByCalendarMask = new HashMap<>();
673+
for (ServiceCalendar calendar : dao.getAllCalendars()) {
674+
String serviceId = calendar.getServiceId().getId();
675+
serviceIds.add(serviceId);
676+
serviceIdByCalendarMask.put(getCalendarMask(calendar), serviceId);
677+
}
678+
assertEquals(3, dao.getAllCalendars().size(), "expected three logical calendars");
679+
assertEquals(3, serviceIds.size(), "expected three distinct raw service ids");
680+
assertEquals(3, serviceIdByCalendarMask.size(), "expected three distinct calendar masks");
681+
assertEquals(
682+
"T0",
683+
serviceIdByCalendarMask.get("0000011"),
684+
"higher-priority T0 calendar must remain unchanged");
685+
assertEquals(
686+
"a-T0",
687+
serviceIdByCalendarMask.get("1010101"),
688+
"higher-priority a-T0 calendar must remain unchanged");
689+
690+
String freshId = serviceIdByCalendarMask.get("1111100");
691+
assertNotNull(freshId, "expected the fresh calendar to receive a third, unused id");
692+
assertTrue(!freshId.equals("T0") && !freshId.equals("a-T0"));
693+
694+
Map<String, String> serviceIdByTripId = new HashMap<>();
695+
for (Trip trip : dao.getAllTrips()) {
696+
serviceIdByTripId.put(trip.getId().getId(), trip.getServiceId().getId());
697+
}
698+
assertEquals(3, dao.getAllTrips().size(), "expected one trip for each logical calendar");
699+
assertEquals(freshId, serviceIdByTripId.get("fresh-trip"));
700+
assertEquals("T0", serviceIdByTripId.get("target-t0-trip"));
701+
assertEquals("a-T0", serviceIdByTripId.get("target-a-t0-trip"));
702+
703+
Map<String, String> serviceIdByExceptionDate = new HashMap<>();
704+
for (ServiceCalendarDate date : dao.getAllCalendarDates()) {
705+
serviceIdByExceptionDate.put(date.getDate().getAsString(), date.getServiceId().getId());
706+
}
707+
assertEquals(
708+
3, dao.getAllCalendarDates().size(), "expected one date for each logical calendar");
709+
assertEquals(3, serviceIdByExceptionDate.size(), "expected three distinct exception dates");
710+
assertEquals(freshId, serviceIdByExceptionDate.get("20120704"));
711+
assertEquals("T0", serviceIdByExceptionDate.get("20120705"));
712+
assertEquals("a-T0", serviceIdByExceptionDate.get("20120706"));
713+
714+
// no ambiguity in the round-tripped output: exactly one calendar per raw service id
715+
for (String id : serviceIds) {
716+
int count = 0;
717+
for (ServiceCalendar calendar : dao.getAllCalendars()) {
718+
if (id.equals(calendar.getServiceId().getId())) {
719+
count++;
720+
}
721+
}
722+
assertEquals(1, count, "expected exactly one calendar for service id=" + id);
723+
}
724+
}
725+
726+
private String getCalendarMask(ServiceCalendar calendar) {
727+
return ""
728+
+ calendar.getMonday()
729+
+ calendar.getTuesday()
730+
+ calendar.getWednesday()
731+
+ calendar.getThursday()
732+
+ calendar.getFriday()
733+
+ calendar.getSaturday()
734+
+ calendar.getSunday();
735+
}
736+
631737
private GtfsRelationalDao merge() throws IOException {
632738
List<File> paths = new ArrayList<>();
633739
paths.add(_oldGtfs.getPath());

0 commit comments

Comments
 (0)