View Javadoc
1   package de.dlr.shepard.migrations.neo4j;
2   
3   import static de.dlr.shepard.common.util.Neo4jLabels.HAS_TIMESERIES_TUPLE;
4   import static de.dlr.shepard.common.util.Neo4jLabels.IS_IN_CONTAINER;
5   import static org.junit.jupiter.api.Assertions.assertEquals;
6   import static org.junit.jupiter.api.Assertions.assertFalse;
7   import static org.junit.jupiter.api.Assertions.assertNotEquals;
8   import static org.junit.jupiter.api.Assertions.assertTrue;
9   import static org.neo4j.cypherdsl.core.Cypher.literalOf;
10  import static org.neo4j.cypherdsl.core.Cypher.node;
11  
12  import com.opencsv.CSVReaderHeaderAware;
13  import com.opencsv.exceptions.CsvValidationException;
14  import de.dlr.shepard.common.util.Neo4jLabels;
15  import de.dlr.shepard.data.timeseries.model.Timeseries;
16  import de.dlr.shepard.data.timeseries.model.TimeseriesContainer;
17  import de.dlr.shepard.data.timeseries.model.TimeseriesTuple;
18  import de.dlr.shepard.data.timeseries.model.enums.DataPointValueType;
19  import java.io.FileReader;
20  import java.io.IOException;
21  import java.math.BigDecimal;
22  import java.nio.file.Files;
23  import java.nio.file.Path;
24  import java.sql.Connection;
25  import java.sql.DriverManager;
26  import java.sql.SQLException;
27  import java.util.ArrayList;
28  import java.util.Comparator;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.Optional;
32  import java.util.stream.Stream;
33  import org.apache.commons.lang3.NotImplementedException;
34  import org.eclipse.microprofile.config.ConfigProvider;
35  import org.flywaydb.core.Flyway;
36  import org.jspecify.annotations.NonNull;
37  import org.neo4j.cypherdsl.core.Cypher;
38  import org.neo4j.cypherdsl.core.Node;
39  
40  public class TestV13 extends MigrationTest {
41  
42    private List<Timeseries> allTimeseries;
43  
44    @Override
45    public void setupPreMigrationData() throws CsvValidationException, IOException, ClassNotFoundException {
46      clearDatabase();
47      var containerIdMappings = preparePreexistingNeo4jData();
48      var containeredTimeseries = prepareV13TimescaleData(containerIdMappings);
49      allTimeseries = containeredTsToInternalRep(containeredTimeseries)
50        .sorted(Comparator.comparing(Timeseries::getTimeseriesId))
51        .toList();
52    }
53  
54    @Override
55    public String getTargetVersion() {
56      return "V13";
57    }
58  
59    private void clearDatabase() {
60      q.deleteAll("Timeseries");
61      q.deleteAll("TimeseriesTuple");
62      q.deleteAll("TimeseriesContainer");
63      q.deleteAll("TimeseriesReference");
64    }
65  
66    /**
67     * Assert that the timeseries from timescale are now present as timeseries nodes in the graph database.
68     */
69    public void assertTimeseriesPresentInGraphDb() {
70      var ts_result_list = q.match(node("Timeseries"));
71      assertEquals(8, ts_result_list.size());
72      for (var ts : allTimeseries) {
73        var tsListActual = q.match(
74          node("Timeseries")
75            .withProperties(
76              "timeseriesId",
77              Cypher.literalOf(ts.getTimeseriesId()),
78              "valueType",
79              Cypher.literalOf(ts.getValueType().toString())
80            )
81            .relationshipTo(
82              node("TimeseriesContainer").withProperties("name", Cypher.literalOf(ts.getContainer().getName())),
83              IS_IN_CONTAINER
84            )
85            .getLeft()
86            .relationshipTo(
87              node("TimeseriesTuple").withProperties(
88                "measurement",
89                Cypher.literalOf(ts.getTimeseriesTuple().getMeasurement())
90              ),
91              Neo4jLabels.HAS_TIMESERIES_TUPLE
92            )
93            .getLeft()
94        );
95        assertEquals(1, tsListActual.size());
96      }
97    }
98  
99    /**
100    * Assert that meta-information does not exist anymore in timescaledb.
101    */
102   public void assertMetadataDeletedInTimeseriesDb() throws SQLException, ClassNotFoundException {
103     try (var connection = createTimeseriesConnection()) {
104       var result = connection
105         .prepareStatement("select tablename from pg_tables where tablename = 'timeseries'")
106         .executeQuery();
107       assertFalse(result.next());
108     }
109   }
110 
111   /**
112    * Assert that the values of all timeseries are still intact in timescaledb.
113    */
114   public void assertTimeseriesDatapointsIntact() throws SQLException, ClassNotFoundException {
115     try (var connection = createTimeseriesConnection()) {
116       var tsCount = connection
117         .prepareStatement("select count(timeseries_id) from timeseries_data_points")
118         .executeQuery();
119       tsCount.next();
120       assertEquals(15, tsCount.getInt("count"));
121       var actualTsIds = connection
122         .prepareStatement("select timeseries_id from timeseries_data_points group by timeseries_id")
123         .executeQuery();
124       while (actualTsIds.next()) assertTrue(
125         allTimeseries.stream().map(Timeseries::getTimeseriesId).toList().contains(actualTsIds.getLong("timeseries_id"))
126       );
127     }
128   }
129 
130   private final Node intLevelTs = node("TimeseriesTuple")
131     .withProperties(
132       "device",
133       literalOf("device"),
134       "field",
135       literalOf("field"),
136       "location",
137       literalOf("location"),
138       "measurement",
139       literalOf("int_level"),
140       "symbolicName",
141       literalOf("symbolicName")
142     )
143     .named("ts");
144 
145   private final Node tsc1 = sample.timeseriesContainer(1).named("tsc1");
146   private final Node tsc2 = sample.timeseriesContainer(2).named("tsc2");
147 
148   /**
149    * Prepare the preexisting data in Neo4j.
150    * Those are one already existing timeseries tuple and two timeseries containers.
151    *
152    * @return The mapping of the "testing" container id which is originally assigned to timeseries to the actual
153    * container ids which are generated by neo4j.
154    * This is a crutch around not being able to predetermine the container id for the test case since we rely on the
155    * internalId of Neo4j.
156    */
157   private Map<Long, Long> preparePreexistingNeo4jData() {
158     q.create(intLevelTs);
159     var objectTsc1 = q.create(tsc1, TimeseriesContainer.class);
160     var objectTsc2 = q.create(tsc2, TimeseriesContainer.class);
161     return Map.of(1L, objectTsc1.getId(), 2L, objectTsc2.getId());
162   }
163 
164   public void assertNewTimeseriesMergedWithPreexisting() {
165     // assert node was not duplicated
166     assertEquals(1, q.match(intLevelTs).size());
167     // assert node is referenced by two Timeseries
168     var ts1 = node("Timeseries")
169       .withProperties("valueType", Cypher.literalOf(DataPointValueType.Integer.name()))
170       .named("ts1");
171     var ts2 = node("Timeseries")
172       .withProperties("valueType", Cypher.literalOf(DataPointValueType.Integer.name()))
173       .named("ts2");
174     var st = Cypher.match(
175       ts1.relationshipTo(tsc1, IS_IN_CONTAINER),
176       ts2.relationshipTo(tsc2, IS_IN_CONTAINER),
177       ts1.relationshipTo(intLevelTs, HAS_TIMESERIES_TUPLE),
178       ts2.relationshipTo(intLevelTs, HAS_TIMESERIES_TUPLE)
179     )
180       .returning(ts1, ts2)
181       .build();
182     var resList = q
183       .queryResults(st, Timeseries.class)
184       .stream()
185       .map(Timeseries::getId)
186       .map(id -> q.loadSingle(id, Timeseries.class))
187       .toList();
188     assertTrue(allTimeseries.containsAll(resList));
189     assertNotEquals(resList.get(0).getTimeseriesId(), resList.get(1).getTimeseriesId());
190   }
191 
192   private Connection createTimeseriesConnection() throws SQLException, ClassNotFoundException {
193     Class.forName("org.postgresql.Driver");
194     var url = ConfigProvider.getConfig().getValue("quarkus.datasource.jdbc.url", String.class);
195     var user = ConfigProvider.getConfig().getValue("quarkus.datasource.username", String.class);
196     var pass = ConfigProvider.getConfig().getValue("quarkus.datasource.password", String.class);
197     return DriverManager.getConnection(url, user, pass);
198   }
199 
200   /**
201    * Prepare the timeseries database for the migration of timeseries metadata towards the graph database.
202    */
203   private Stream<ExtendedTimeseries> prepareV13TimescaleData(Map<Long, Long> containerIdMappings)
204     throws CsvValidationException, IOException, ClassNotFoundException {
205     Class.forName("org.postgresql.Driver");
206     var url = ConfigProvider.getConfig().getValue("quarkus.datasource.jdbc.url", String.class);
207     var user = ConfigProvider.getConfig().getValue("quarkus.datasource.username", String.class);
208     var pass = ConfigProvider.getConfig().getValue("quarkus.datasource.password", String.class);
209     Flyway flyway = Flyway.configure()
210       .target("1.7.0")
211       .dataSource(url, user, pass)
212       .locations("db/migration", "classpath:de/dlr/shepard/data/timeseries/migrations")
213       .load();
214     flyway.migrate();
215 
216     var dbEntries = readCsvAsMapList("src/test/resources/timeseries_import_migration_test.csv");
217 
218     return dbEntries
219       .stream()
220       .map(TestV13::csvEntryToTs)
221       // replace the dummy container ids 1 and 2 with the actual ids from Neo4j
222       .map(t -> new DatapointCsvEntry(containerIdMappings.get(t.containerId()), t.timestamp(), t.value(), t.quintuple())
223       )
224       .map(this::addTimeseriesToTimescale)
225       .filter(Optional::isPresent)
226       .map(Optional::get);
227   }
228 
229   private record DatapointCsvEntry(long containerId, long timestamp, Object value, TimeseriesTuple quintuple) {}
230 
231   private record ExtendedTimeseries(
232     long containerId,
233     long timeseriesId,
234     TimeseriesTuple quintuple,
235     DataPointValueType valueType
236   ) {}
237 
238   /**
239    * Add the first point of the Timeseries to the timescale table timeseries_with_data_points.
240    * If the timeseries does not exist yet create it and return its id including the Timeseries.
241    *
242    * @param ts Timeseries that must have only one data point
243    * @return Empty optional if timeseries already existed, otherwise timeseries id and Timeseries.
244    */
245   private Optional<ExtendedTimeseries> addTimeseriesToTimescale(DatapointCsvEntry ts) {
246     try (var connection = createTimeseriesConnection()) {
247       Optional<Long> ts_id = insertTimeseries(ts, connection);
248       insertTimeseriesDataPoint(connection, ts);
249       var valueType = valueToValueType(ts.value());
250       return ts_id.map(tsIdValue -> new ExtendedTimeseries(ts.containerId, tsIdValue, ts.quintuple(), valueType));
251     } catch (SQLException | IOException | ClassNotFoundException e) {
252       throw new RuntimeException(e);
253     }
254   }
255 
256   private static @NonNull Optional<Long> insertTimeseries(DatapointCsvEntry ts, Connection connection)
257     throws IOException, SQLException {
258     var sql = Files.readString(Path.of("src/test/resources/insert_timeseries.sql"));
259     var stmt = connection.prepareStatement(sql);
260     var valueType = valueToValueType(ts.value());
261     stmt.setBigDecimal(1, BigDecimal.valueOf(ts.containerId()));
262     stmt.setString(2, ts.quintuple().getMeasurement());
263     stmt.setString(3, ts.quintuple().getField());
264     stmt.setString(4, ts.quintuple().getSymbolicName());
265     stmt.setString(5, ts.quintuple().getDevice());
266     stmt.setString(6, ts.quintuple().getLocation());
267     stmt.setString(7, valueType.toString());
268     var resultSet = stmt.executeQuery();
269     return resultSet.next() ? Optional.of(resultSet.getLong(1)) : Optional.empty();
270   }
271 
272   private void insertTimeseriesDataPoint(Connection connection, DatapointCsvEntry ts) throws IOException, SQLException {
273     var sql2 = Files.readString(Path.of("src/test/resources/insert_timeseries_data_point.sql"));
274     sql2 = sql2.replace(":column", getDatapointColumn(ts.value()));
275     var stmt2 = connection.prepareStatement(sql2);
276     stmt2.setBigDecimal(1, BigDecimal.valueOf(ts.containerId()));
277     stmt2.setString(2, ts.quintuple().getMeasurement());
278     stmt2.setString(3, ts.quintuple().getField());
279     stmt2.setString(4, ts.quintuple().getSymbolicName());
280     stmt2.setString(5, ts.quintuple().getDevice());
281     stmt2.setString(6, ts.quintuple().getLocation());
282     stmt2.setBigDecimal(7, BigDecimal.valueOf(ts.timestamp()));
283     stmt2.setObject(8, ts.value());
284     stmt2.executeUpdate();
285   }
286 
287   private Stream<Timeseries> containeredTsToInternalRep(Stream<ExtendedTimeseries> tsList) {
288     return tsList.map(ts -> {
289       var container = q.loadSingle(ts.containerId(), TimeseriesContainer.class);
290       return new Timeseries(container, ts.quintuple(), ts.valueType(), ts.timeseriesId());
291     });
292   }
293 
294   /**
295    * Get the column name into which a timeseries data point should be saved in timescale.
296    */
297   private static String getDatapointColumn(Object value) {
298     if (value instanceof Double) return "double_value";
299     else if (value instanceof String) return "string_value";
300     else if (value instanceof Boolean) return "boolean_value";
301     else if (value instanceof Integer) return "int_value";
302     throw new RuntimeException("Data point " + value + " is of unfitting value!");
303   }
304 
305   private static DatapointCsvEntry csvEntryToTs(Map<String, String> entry) {
306     return new DatapointCsvEntry(
307       Long.parseLong(entry.get("CONTAINERID")),
308       Long.parseLong(entry.get("TIMESTAMP")),
309       strValueToObject(entry.get("VALUE")),
310       new TimeseriesTuple(
311         entry.get("MEASUREMENT"),
312         entry.get("DEVICE"),
313         entry.get("LOCATION"),
314         entry.get("SYMBOLICNAME"),
315         entry.get("FIELD")
316       )
317     );
318   }
319 
320   /**
321    * Convert a string value of a timeseries to an Object of type Integer, Double, Boolean or String.
322    */
323   private static Object strValueToObject(String strValue) {
324     try {
325       return Integer.valueOf(strValue);
326     } catch (NumberFormatException e1) {
327       try {
328         return Double.valueOf(strValue);
329       } catch (NumberFormatException e2) {
330         if ("true".equalsIgnoreCase(strValue) || "false".equalsIgnoreCase(strValue)) return Boolean.valueOf(strValue);
331         else return strValue;
332       }
333     }
334   }
335 
336   /**
337    * Determine the value type of a timeseries value
338    *
339    * @param value Object that contains the value of the timeseries data point
340    * @return The type corresponding to the value
341    */
342   private static DataPointValueType valueToValueType(Object value) {
343     var strValue = value.toString();
344     try {
345       Integer.valueOf(strValue);
346       return DataPointValueType.Integer;
347     } catch (NumberFormatException e1) {
348       try {
349         Double.valueOf(strValue);
350         return DataPointValueType.Double;
351       } catch (NumberFormatException e2) {
352         if ("true".equalsIgnoreCase(strValue) || "false".equalsIgnoreCase(strValue)) return DataPointValueType.Boolean;
353         else return DataPointValueType.String;
354       }
355     }
356   }
357 
358   /**
359    * Read a csv file into a list of maps each mapping header to corresponding value.
360    */
361   private static List<Map<String, String>> readCsvAsMapList(String csvFilePath)
362     throws IOException, CsvValidationException {
363     CSVReaderHeaderAware reader = new CSVReaderHeaderAware(new FileReader(csvFilePath));
364 
365     List<Map<String, String>> rows = new ArrayList<>();
366     Map<String, String> rowMap;
367 
368     while ((rowMap = reader.readMap()) != null) {
369       rows.add(rowMap);
370     }
371 
372     return rows;
373   }
374 
375   public void cleanup() {
376     throw new NotImplementedException();
377   }
378 }