Skip to content

Commit

Permalink
feat: Validation for timeframes.txt (#1518)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdferris-v2 authored Jun 29, 2023
1 parent d0cfd80 commit 784a664
Show file tree
Hide file tree
Showing 9 changed files with 700 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public interface GtfsFareLegRuleSchema extends GtfsEntity {
@ForeignKey(table = "areas.txt", field = "area_id")
String toAreaId();

@FieldType(FieldTypeEnum.ID)
@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@ForeignKey(table = "timeframes.txt", field = "timeframe_group_id")
String fromTimeframeGroupId();

@FieldType(FieldTypeEnum.ID)
@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@ForeignKey(table = "timeframes.txt", field = "timeframe_group_id")
String toTimeframeGroupId();

@FieldType(FieldTypeEnum.ID)
@Required
@PrimaryKey(translationRecordIdType = UNSUPPORTED)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.mobilitydata.gtfsvalidator.table;

import static org.mobilitydata.gtfsvalidator.annotation.TranslationRecordIdType.UNSUPPORTED;

import org.mobilitydata.gtfsvalidator.annotation.ConditionallyRequired;
import org.mobilitydata.gtfsvalidator.annotation.EndRange;
import org.mobilitydata.gtfsvalidator.annotation.FieldType;
import org.mobilitydata.gtfsvalidator.annotation.FieldTypeEnum;
import org.mobilitydata.gtfsvalidator.annotation.GtfsTable;
import org.mobilitydata.gtfsvalidator.annotation.Index;
import org.mobilitydata.gtfsvalidator.annotation.PrimaryKey;
import org.mobilitydata.gtfsvalidator.annotation.Required;
import org.mobilitydata.gtfsvalidator.type.GtfsTime;

@GtfsTable("timeframes.txt")
public interface GtfsTimeframeSchema extends GtfsEntity {

@FieldType(FieldTypeEnum.ID)
@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@Index
String timeframeGroupId();

@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@ConditionallyRequired
@EndRange(field = "end_time", allowEqual = false)
GtfsTime startTime();

@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@ConditionallyRequired
GtfsTime endTime();

@PrimaryKey(translationRecordIdType = UNSUPPORTED)
@Required
String serviceId();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package org.mobilitydata.gtfsvalidator.validator;

import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

import com.google.auto.value.AutoValue;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator;
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
import org.mobilitydata.gtfsvalidator.notice.ValidationNotice;
import org.mobilitydata.gtfsvalidator.table.GtfsFrequencySchema;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer;
import org.mobilitydata.gtfsvalidator.type.GtfsTime;

/**
* Validates that two entries from `timesframes.txt` with the same `timeframe_group_id` and
* `service_id` do not have overlapping time intervals.
*/
@GtfsValidator
public class TimeframeOverlapValidator extends FileValidator {

private final GtfsTimeframeTableContainer timeframeContainer;

@Inject
public TimeframeOverlapValidator(GtfsTimeframeTableContainer timeframeContainer) {
this.timeframeContainer = timeframeContainer;
}

@Override
public void validate(NoticeContainer noticeContainer) {
Map<TimeframeKey, List<GtfsTimeframe>> timeframesByKey =
timeframeContainer.getEntities().stream()
.collect(Collectors.groupingBy(TimeframeKey::create, Collectors.toList()));
for (Map.Entry<TimeframeKey, List<GtfsTimeframe>> entry : timeframesByKey.entrySet()) {
List<GtfsTimeframe> timeframes = new ArrayList<>(entry.getValue());
Collections.sort(
timeframes,
Comparator.comparing(GtfsTimeframe::startTime).thenComparing(GtfsTimeframe::endTime));
for (int i = 1; i < timeframes.size(); ++i) {
GtfsTimeframe prev = timeframes.get(i - 1);
GtfsTimeframe curr = timeframes.get(i);
if (curr.startTime().isBefore(prev.endTime())) {
noticeContainer.addValidationNotice(
new TimeframeOverlapNoice(
prev.csvRowNumber(),
prev.endTime(),
curr.csvRowNumber(),
curr.startTime(),
entry.getKey().timeframeGroupId(),
entry.getKey().serviceId()));
}
}
}
}

@AutoValue
abstract static class TimeframeKey {
abstract String timeframeGroupId();

abstract String serviceId();

static TimeframeKey create(GtfsTimeframe timeframe) {
return new AutoValue_TimeframeOverlapValidator_TimeframeKey(
timeframe.timeframeGroupId(), timeframe.serviceId());
}
}

/**
* Two entries in `timeframes.txt` with the same `timeframe_group_id` and `service_id` have
* overlapping time intervals.
*
* <p>Timeframes with the same group and service dates must not overlap in time. Two entries X and
* Y are considered to directly overlap if `X.start_time &lt;= Y.start_time` and `Y.start_time
* &lt; X.end_time`.
*/
@GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsFrequencySchema.class))
static class TimeframeOverlapNoice extends ValidationNotice {

/** The row number of the first timeframe entry. */
private final long prevCsvRowNumber;

/** The first timeframe end time. */
private final GtfsTime prevEndTime;

/** The row number of the second timeframe entry. */
private final long currCsvRowNumber;

/** The start time of the second timeframe entry. */
private final GtfsTime currStartTime;

/** The timeframe group id associated with the two entries. */
private final String timeframeGroupId;

/** The service id associated with the two entries. */
private final String serviceId;

TimeframeOverlapNoice(
long prevCsvRowNumber,
GtfsTime prevEndTime,
long currCsvRowNumber,
GtfsTime currStartTime,
String timeframeGroupId,
String serviceId) {
this.prevCsvRowNumber = prevCsvRowNumber;
this.prevEndTime = prevEndTime;
this.currCsvRowNumber = currCsvRowNumber;
this.currStartTime = currStartTime;
this.timeframeGroupId = timeframeGroupId;
this.serviceId = serviceId;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.mobilitydata.gtfsvalidator.validator;

import javax.inject.Inject;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator;
import org.mobilitydata.gtfsvalidator.notice.ForeignKeyViolationNotice;
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
import org.mobilitydata.gtfsvalidator.table.GtfsCalendar;
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDate;
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDateTableContainer;
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarTableContainer;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer;

/**
* Validates that `service_id` field in `timeframes.txt` references a valid `service_id` in
* `calendar.txt` or `calendar_date.txt`.
*/
@GtfsValidator
public class TimeframeServiceIdForeignKeyValidator extends FileValidator {
private final GtfsTimeframeTableContainer timeframeContainer;
private final GtfsCalendarTableContainer calendarContainer;
private final GtfsCalendarDateTableContainer calendarDateContainer;

@Inject
TimeframeServiceIdForeignKeyValidator(
GtfsTimeframeTableContainer timeframeContainer,
GtfsCalendarTableContainer calendarContainer,
GtfsCalendarDateTableContainer calendarDateContainer) {
this.timeframeContainer = timeframeContainer;
this.calendarContainer = calendarContainer;
this.calendarDateContainer = calendarDateContainer;
}

@Override
public void validate(NoticeContainer noticeContainer) {
for (GtfsTimeframe timeframe : timeframeContainer.getEntities()) {
String childKey = timeframe.serviceId();
if (!hasReferencedKey(childKey, calendarContainer, calendarDateContainer)) {
noticeContainer.addValidationNotice(
new ForeignKeyViolationNotice(
GtfsTimeframe.FILENAME,
GtfsTimeframe.SERVICE_ID_FIELD_NAME,
GtfsCalendar.FILENAME + " or " + GtfsCalendarDate.FILENAME,
GtfsCalendar.SERVICE_ID_FIELD_NAME,
childKey,
timeframe.csvRowNumber()));
}
}
}

private boolean hasReferencedKey(
String childKey,
GtfsCalendarTableContainer calendarContainer,
GtfsCalendarDateTableContainer calendarDateContainer) {
return calendarContainer.byServiceId(childKey).isPresent()
|| !calendarDateContainer.byServiceId(childKey).isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.mobilitydata.gtfsvalidator.validator;

import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator;
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
import org.mobilitydata.gtfsvalidator.notice.ValidationNotice;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe;
import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeSchema;
import org.mobilitydata.gtfsvalidator.type.GtfsTime;

/**
* Validates the `start_time` and `end_time` values from `timeframes.txt`, checking that either both
* are present or neither. Also checks that no value is greater than 24-hours.
*/
@GtfsValidator
public class TimeframeStartAndEndTimeValidator extends SingleEntityValidator<GtfsTimeframe> {

private static final GtfsTime TWENTY_FOUR_HOURS = GtfsTime.fromHourMinuteSecond(24, 0, 0);

@Override
public void validate(GtfsTimeframe entity, NoticeContainer noticeContainer) {
if (entity.hasStartTime() ^ entity.hasEndTime()) {
noticeContainer.addValidationNotice(
new TimeframeOnlyStartOrEndTimeSpecifiedNotice(entity.csvRowNumber()));
}
if (entity.hasStartTime() && entity.startTime().isAfter(TWENTY_FOUR_HOURS)) {
noticeContainer.addValidationNotice(
new TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice(
entity.csvRowNumber(), GtfsTimeframe.START_TIME_FIELD_NAME, entity.startTime()));
}
if (entity.hasEndTime() && entity.endTime().isAfter(TWENTY_FOUR_HOURS)) {
noticeContainer.addValidationNotice(
new TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice(
entity.csvRowNumber(), GtfsTimeframe.END_TIME_FIELD_NAME, entity.endTime()));
}
}

/**
* A row from `timeframes.txt` was found with only one of `start_time` and `end_time` specified.
*
* <p>Either both must be specified or neither must be specified.
*/
@GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsTimeframeSchema.class))
static class TimeframeOnlyStartOrEndTimeSpecifiedNotice extends ValidationNotice {

/** The row number for the faulty record. */
private final int csvRowNumber;

public TimeframeOnlyStartOrEndTimeSpecifiedNotice(int csvRowNumber) {
this.csvRowNumber = csvRowNumber;
}
}

/** A time in `timeframes.txt` is greater than `24:00:00`. */
@GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsTimeframeSchema.class))
static class TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice extends ValidationNotice {
/** The row number for the faulty record. */
private final int csvRowNumber;
/** The time field name for the faulty record. */
private final String fieldName;
/** The invalid time value. */
private final GtfsTime time;

TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice(
int csvRowNumber, String fieldName, GtfsTime time) {
this.csvRowNumber = csvRowNumber;
this.fieldName = fieldName;
this.time = time;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ public void testNoticeClassFieldNames() {
"stopUrl",
"suggestedExpirationDate",
"tableName",
"time",
"timeframeGroupId",
"transferCount",
"tripCsvRowNumber",
"tripFieldName",
Expand Down
Loading

0 comments on commit 784a664

Please sign in to comment.