Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Validation for timeframes.txt #1518

Merged
merged 8 commits into from
Jun 29, 2023
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