diff --git a/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java new file mode 100644 index 00000000..b7a56e8d --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java @@ -0,0 +1,184 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public abstract class BaseGBFSFileDeltaCalculator implements GBFSFileDeltaCalculator { + private static final List EXCLUDE_METHODS = List.of( + "toString", + "hashCode", + "equals" + ); + + @Override + public final GBFSFileDelta calculateDelta(S base, @NotNull S compare) { + List> entityDeltas = getEntityDeltas(base, compare); + return getGBFSFileDelta(base, compare, entityDeltas); + } + + private @NotNull GBFSFileDelta getGBFSFileDelta(S base, @NotNull S compare, List> entityDeltas) { + return new GBFSFileDelta<>( + getLastUpdated(base), + getLastUpdated(compare), + getFileName(), + entityDeltas + ); + } + + private @NotNull List> getEntityDeltas(S base, @NotNull S compare) { + List baseEntities = getBaseEntities(base); + Map baseEntityMap = getBaseEntityMap(baseEntities); + List baseEntityIds = getEntityIds(baseEntityMap); + List compareEntities = getEntities(compare); + List compareEntityIds = getEntityIds(compareEntities); + + return Stream.of( + getDeletedEntityDeltas(baseEntities, compareEntityIds), + getKeptEntityDeltas(compareEntities, baseEntityMap, baseEntityIds) + ).flatMap(Collection::stream).toList(); + } + + private @NotNull List getEntityIds(Map entityMap) { + return entityMap.keySet().stream().toList(); + } + + private @NotNull List getEntityIds(List entities) { + return entities.stream().map(this::getEntityId).toList(); + } + + private @NotNull List getBaseEntities(S base) { + return base != null ? getEntities(base) : List.of(); + } + + private @NotNull Map getBaseEntityMap(List baseEntities) { + return baseEntities.stream().collect(Collectors.toMap(this::getEntityId, v -> v)); + } + + private @NotNull List> getDeletedEntityDeltas(List baseEntities, List compareEntityIds) { + return baseEntities.stream() + .map(this::getEntityId) + .filter(id -> !compareEntityIds.contains(id)) + .map(id -> + new GBFSEntityDelta( + id, + DeltaType.DELETE, + null + )).toList(); + } + + private @NotNull List> getKeptEntityDeltas(List compareEntities, Map baseEntityMap, List baseEntityIds) { + return compareEntities.stream() + + // We do not need to return a delta for entities that haven't changed. We trust the implementation + // of equals from the gbfs model here. + .filter(entity -> !entity.equals(baseEntityMap.get(getEntityId(entity)))) + + .map(entity -> { + var entityId = getEntityId(entity); + // If the entity exists in the base, then this delta is an update, and we can compute + // the entity delta + if (baseEntityIds.contains(entityId)) { + return new GBFSEntityDelta<>( + entityId, + DeltaType.UPDATE, + getEntityDelta(baseEntityMap.get(entityId), entity) + ); + + // Otherwise, this is a new entity, and the "delta" contains the entire entity + } else { + return new GBFSEntityDelta<>( + entityId, + DeltaType.CREATE, + entity + ); + } + }).toList(); + } + + private T getEntityDelta(T a, T b) { + T delta = createEntity(); + Method[] methods = a.getClass().getDeclaredMethods(); + for (Method method : methods) { + try { + if (!EXCLUDE_METHODS.contains(method.getName()) && method.getParameterCount() == 0 && (method.invoke(a) == null || !method.invoke(a).equals(method.invoke(b)))) { + getSetter(methods, method.getName()).ifPresent(setter -> { + try { + setter.invoke(delta, method.invoke(b)); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + }); + + } + } catch (InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return delta; + } + + private @NotNull Optional getSetter(Method[] methods, String getterName) { + String setterName = getterName.replace("get", "set"); + return Arrays.stream(methods).filter(method1 -> method1.getName().equals(setterName)).findFirst(); + } + + /** + * Get a list of enumerable entities from the GBFS file instance + * @param instance The GBFS file instance + * @return List of enumerable entities of type T + */ + protected abstract List getEntities(S instance); + + /** + * Get the id of the entity + * @param entity The entity + * @return The entity's id + */ + protected abstract String getEntityId(T entity); + + /** + * Create a new instance of the entity of type T + * @return An instance of T + */ + protected abstract T createEntity(); + + /** + * Get the last updated time of the GBFS file instance + * @param instance The GBFS file instance + * @return The last updated time + */ + protected abstract long getLastUpdated(S instance); + + /** + * Get the file name of the GBFS file + * @return The file name + */ + protected abstract String getFileName(); +} diff --git a/src/main/java/org/entur/lamassu/delta/DeltaType.java b/src/main/java/org/entur/lamassu/delta/DeltaType.java new file mode 100644 index 00000000..8831bbde --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/DeltaType.java @@ -0,0 +1,40 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +/** + * Enum representing the type of delta. + */ +public enum DeltaType { + + /** + * A new entity was created + */ + CREATE, + + /** + * En existing entity was updated + */ + UPDATE, + + /** + * An existing entity was deleted + */ + DELETE +} diff --git a/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java b/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java new file mode 100644 index 00000000..8ef3022a --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java @@ -0,0 +1,33 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +/** + * A record representing the difference (delta) between two enumerable entities of a GBFS file + * @param entityId The unique ID of the entity + * @param type The type of the delta (create, update or delete) + * @param entity An instance of the entity itself, containing the changed fields. + * Note: this field is non-null only if the delta type is "UPDATE" + * @param The type of the enumerable entity that was compared + */ +public record GBFSEntityDelta( + String entityId, + DeltaType type, + E entity +) {} diff --git a/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java b/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java new file mode 100644 index 00000000..8651a28a --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java @@ -0,0 +1,37 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +import java.util.List; + +/** + * Record representing the difference between two instances of a GBFS file + * + * @param base Numerical identifier of the base of the comparison + * @param compare Numerical identifier of the compare side + * @param fileName The file name of the GBFS files being compared + * @param entityDelta A list of entity deltas for the enumerable entity of type E in the GBFS file + * @param The type of the enumerable entity in the GBFS file + */ +public record GBFSFileDelta( + Long base, + Long compare, + String fileName, + List> entityDelta +) {} diff --git a/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java new file mode 100644 index 00000000..990fdf95 --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java @@ -0,0 +1,43 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +import org.jetbrains.annotations.NotNull; + +/** + * Represents a calculator that can compute the delta (difference) between to instances + * of a GBFS file. + * + * @param The type of the GBFS file instances to compare + * @param The type of the enumerable entity inside the GBFS file being compared + */ +public interface GBFSFileDeltaCalculator { + + /** + * Calculate the delta (difference) between to instances of a GBFS file of type S + * + * @param base The base of the comparison + * Note: This parameter can be null, in it is then interpreted as a full update + * + * @param compare The instance to compare with the base + * Note: This parameter can't be null + * @return An instance of GBFSFileDelta containing deltas of the enumerable entity of type T + */ + GBFSFileDelta calculateDelta(S base, @NotNull S compare); +} diff --git a/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java new file mode 100644 index 00000000..a1ad278e --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java @@ -0,0 +1,54 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +import org.mobilitydata.gbfs.v3_0.station_status.GBFSStation; +import org.mobilitydata.gbfs.v3_0.station_status.GBFSStationStatus; + +import java.util.List; + +public class GBFSStationStatusDeltaCalculator extends BaseGBFSFileDeltaCalculator { + + public static final String FILE_NAME = "station_status"; + + @Override + protected List getEntities(GBFSStationStatus instance) { + return instance.getData().getStations(); + } + + @Override + protected String getEntityId(GBFSStation entity) { + return entity.getStationId(); + } + + @Override + protected GBFSStation createEntity() { + return new GBFSStation(); + } + + @Override + protected long getLastUpdated(GBFSStationStatus instance) { + return instance.getLastUpdated().getTime(); + } + + @Override + protected String getFileName() { + return FILE_NAME; + } +} diff --git a/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java new file mode 100644 index 00000000..80711781 --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java @@ -0,0 +1,54 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.delta; + +import org.mobilitydata.gbfs.v3_0.vehicle_status.GBFSVehicle; +import org.mobilitydata.gbfs.v3_0.vehicle_status.GBFSVehicleStatus; + +import java.util.List; + +public class GBFSVehicleStatusDeltaCalculator extends BaseGBFSFileDeltaCalculator { + + public static final String FILE_NAME = "vehicle_status"; + + @Override + protected List getEntities(GBFSVehicleStatus instance) { + return instance.getData().getVehicles(); + } + + @Override + protected String getEntityId(GBFSVehicle entity) { + return entity.getVehicleId(); + } + + @Override + protected GBFSVehicle createEntity() { + return new GBFSVehicle(); + } + + @Override + protected long getLastUpdated(GBFSVehicleStatus instance) { + return instance.getLastUpdated().getTime(); + } + + @Override + protected String getFileName() { + return FILE_NAME; + } +} diff --git a/src/main/java/org/entur/lamassu/delta/package-info.java b/src/main/java/org/entur/lamassu/delta/package-info.java new file mode 100644 index 00000000..416987d8 --- /dev/null +++ b/src/main/java/org/entur/lamassu/delta/package-info.java @@ -0,0 +1,37 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +/** + * Warning: This feature is experimental and API is subject to change. + * + *

+ * This package offers functionality to compare GBFS files of the same type + * and create a "delta" representation of the comparison. + *

+ * + *

+ *

+ * Use cases: + * * Simplify the entity cache updater logic for stations and vehicles which + * encodes much of the same logic but in a much more complicated way. + * * Offer the possibility to subscribe to deltas via a public API, which has + * the potential to reduce bandwidth and compute resource usage between + * servers and clients + *

+ */ +package org.entur.lamassu.delta;