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
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
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
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
150
151
152
153
154
155
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
166 assertEquals(1, q.match(intLevelTs).size());
167
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
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
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
240
241
242
243
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
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
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
338
339
340
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
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 }