diff --git a/MANUAL.md b/MANUAL.md index 92b245b2..99e1248b 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -323,7 +323,7 @@ public class Cpu { } ``` -2. Add @Measurement,@TimeColumn and @Column annotations (column names default to field names unless otherwise specified): +2. Add @Measurement, @TimeColumn and @Column annotations (column names default to field names unless otherwise specified): ```Java @Measurement(name = "cpu") @@ -364,6 +364,24 @@ public class Cpu { } ``` +Or (if you're on JDK14+ and/or [Android SDK34+](https://android-developers.googleblog.com/2023/06/records-in-android-studio-flamingo.html)): + +```Java +@Measurement(name = "cpu", allFields = true) +public record Cpu( + @TimeColumn + Instant time, + @Column(name = "host", tag = true) + String hostname, + @Column(tag = true) + String region, + Double idle, + Boolean happydevop, + @Column(name = "uptimesecs") + Long uptimeSecs +) {} +``` + 3. Call _InfluxDBResultMapper.toPOJO(...)_ to map the QueryResult to your POJO: ```java diff --git a/pom.xml b/pom.xml index 9db1f257..00cae34e 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,10 @@ 1.8 1.8 + + + -parameters + @@ -404,5 +408,50 @@ + + + java17 + + 17 + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-test-source + generate-test-sources + + add-test-source + + + + src/test-jdk17/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + true + + + 17 + 17 + + + -parameters + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + + + + + + diff --git a/src/main/java/org/influxdb/annotation/Exclude.java b/src/main/java/org/influxdb/annotation/Exclude.java index 01e6f52e..23e07679 100644 --- a/src/main/java/org/influxdb/annotation/Exclude.java +++ b/src/main/java/org/influxdb/annotation/Exclude.java @@ -28,6 +28,9 @@ /** * When a POJO annotated with {@code @Measurement(allFields = true)} is loaded or saved, * this annotation can be used to exclude some of its fields. + *

+ * Note: this is not considered when loading record measurements. + * * @see Measurement#allFields() * * @author Eran Leshem diff --git a/src/main/java/org/influxdb/annotation/Measurement.java b/src/main/java/org/influxdb/annotation/Measurement.java index fa9d19fd..6ea8142e 100644 --- a/src/main/java/org/influxdb/annotation/Measurement.java +++ b/src/main/java/org/influxdb/annotation/Measurement.java @@ -44,6 +44,10 @@ /** * If {@code true}, then all non-static fields of this measurement will be loaded or saved, * regardless of any {@code @Column} annotations. + *

+ * Note: When loading record measurements, this is always implied to be true, + * since the record's canonical constructor is used to populate the record. + * * @see Exclude */ boolean allFields() default false; diff --git a/src/main/java/org/influxdb/impl/InfluxDBResultMapper.java b/src/main/java/org/influxdb/impl/InfluxDBResultMapper.java index 5a4d3af8..2cfdeced 100644 --- a/src/main/java/org/influxdb/impl/InfluxDBResultMapper.java +++ b/src/main/java/org/influxdb/impl/InfluxDBResultMapper.java @@ -26,21 +26,29 @@ import org.influxdb.annotation.Measurement; import org.influxdb.dto.QueryResult; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * Main class responsible for mapping a QueryResult to a POJO. @@ -59,12 +67,26 @@ private static class ClassInfo { private static final ConcurrentMap CLASS_INFO_CACHE = new ConcurrentHashMap<>(); + /** + * Data structure used to cache records used as measurements. + */ + private static class RecordInfo { + Constructor constructor; + ConcurrentMap constructorParamIndexes; + } + private static final + ConcurrentMap RECORD_INFO = new ConcurrentHashMap<>(); + private static final int FRACTION_MIN_WIDTH = 0; private static final int FRACTION_MAX_WIDTH = 9; private static final boolean ADD_DECIMAL_POINT = true; + // Support both standard and Android desugared records + private static final Collection RECORD_CLASS_NAMES = + new HashSet<>(Arrays.asList("java.lang.Record", "com.android.tools.r8.RecordTag")); + /** - * When a query is executed without {@link TimeUnit}, InfluxDB returns the time + * When a query is executed without {@link TimeUnit}, InfluxDB returns the {@code time} * column as a RFC3339 date. */ private static final DateTimeFormatter RFC3339_FORMATTER = new DateTimeFormatterBuilder() @@ -88,7 +110,7 @@ private static class ClassInfo { * same order as received from InfluxDB. * * @throws InfluxDBMapperException If {@link QueryResult} parameter contain errors, - * clazz parameter is not annotated with @Measurement or it was not + * {@code clazz} parameter is not annotated with @Measurement or it was not * possible to define the values of your POJO (e.g. due to an unsupported field type). */ public List toPOJO(final QueryResult queryResult, final Class clazz) throws InfluxDBMapperException { @@ -111,7 +133,7 @@ public List toPOJO(final QueryResult queryResult, final Class clazz) t * same order as received from InfluxDB. * * @throws InfluxDBMapperException If {@link QueryResult} parameter contain errors, - * clazz parameter is not annotated with @Measurement or it was not + * {@code clazz} parameter is not annotated with @Measurement or it was not * possible to define the values of your POJO (e.g. due to an unsupported field type). */ public List toPOJO(final QueryResult queryResult, final Class clazz, @@ -137,7 +159,7 @@ public List toPOJO(final QueryResult queryResult, final Class clazz, * same order as received from InfluxDB. * * @throws InfluxDBMapperException If {@link QueryResult} parameter contain errors, - * clazz parameter is not annotated with @Measurement or it was not + * {@code clazz} parameter is not annotated with @Measurement or it was not * possible to define the values of your POJO (e.g. due to an unsupported field type). */ public List toPOJO(final QueryResult queryResult, final Class clazz, final String measurementName) @@ -162,7 +184,7 @@ public List toPOJO(final QueryResult queryResult, final Class clazz, f * same order as received from InfluxDB. * * @throws InfluxDBMapperException If {@link QueryResult} parameter contain errors, - * clazz parameter is not annotated with @Measurement or it was not + * {@code clazz} parameter is not annotated with @Measurement or it was not * possible to define the values of your POJO (e.g. due to an unsupported field type). */ public List toPOJO(final QueryResult queryResult, final Class clazz, final String measurementName, @@ -174,19 +196,20 @@ public List toPOJO(final QueryResult queryResult, final Class clazz, f Objects.requireNonNull(clazz, "clazz"); throwExceptionIfResultWithError(queryResult); - cacheMeasurementClass(clazz); - List result = new LinkedList(); + if (isRecordClass(clazz)) { + cacheRecordClass(clazz); + } else { + cacheMeasurementClass(clazz); + } + + List result = new LinkedList<>(); queryResult.getResults().stream() .filter(internalResult -> Objects.nonNull(internalResult) && Objects.nonNull(internalResult.getSeries())) - .forEach(internalResult -> { - internalResult.getSeries().stream() - .filter(series -> series.getName().equals(measurementName)) - .forEachOrdered(series -> { - parseSeriesAs(series, clazz, result, precision); - }); - }); + .forEach(internalResult -> internalResult.getSeries().stream() + .filter(series -> series.getName().equals(measurementName)) + .forEachOrdered(series -> parseSeriesAs(series, clazz, result, precision))); return result; } @@ -253,6 +276,49 @@ void cacheMeasurementClass(final Class... classVarAgrs) { } } + static void cacheRecordClass(final Class clazz) { + if (RECORD_INFO.containsKey(clazz.getName())) { + return; + } + + Map components = Arrays.stream(clazz.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .collect(Collectors.toMap(Field::getName, Field::getGenericType)); + boolean found = false; + for (Constructor constructor : clazz.getDeclaredConstructors()) { + Parameter[] parameters = constructor.getParameters(); + Map parameterTypes = Arrays.stream(parameters) + .collect(Collectors.toMap(Parameter::getName, Parameter::getParameterizedType)); + if (!parameterTypes.equals(components)) { + continue; + } + + if (found) { + throw new InfluxDBMapperException(String.format( + "Multiple constructors match set of components for record %s", clazz.getName())); + } + + RecordInfo recordInfo = new RecordInfo(); + recordInfo.constructor = constructor; + + try { + ConcurrentMap constructorParamIndexes = new ConcurrentHashMap<>(parameters.length); + for (int i = 0; i < parameters.length; i++) { + Field field = clazz.getDeclaredField(parameters[i].getName()); + Column colAnnotation = field.getAnnotation(Column.class); + String propertyName = getFieldName(field, colAnnotation); + constructorParamIndexes.put(propertyName, i); + } + recordInfo.constructorParamIndexes = constructorParamIndexes; + } catch (NoSuchFieldException e) { + throw new InfluxDBMapperException(e); + } + + RECORD_INFO.putIfAbsent(clazz.getName(), recordInfo); + found = true; + } + } + private static String getFieldName(final Field field, final Column colAnnotation) { if (colAnnotation != null && !colAnnotation.name().isEmpty()) { return colAnnotation.name(); @@ -281,45 +347,86 @@ List parseSeriesAs(final QueryResult.Series series, final Class clazz, final TimeUnit precision) { int columnSize = series.getColumns().size(); - ClassInfo classInfo = CLASS_INFO_CACHE.get(clazz.getName()); - try { - T object = null; - for (List row : series.getValues()) { - for (int i = 0; i < columnSize; i++) { - Field correspondingField = classInfo.fieldMap.get(series.getColumns().get(i)/*InfluxDB columnName*/); - if (correspondingField != null) { - if (object == null) { - object = clazz.newInstance(); + if (isRecordClass(clazz)) { + RecordInfo recordInfo = RECORD_INFO.get(clazz.getName()); + try { + T object = null; + for (List row : series.getValues()) { + Object[] constructorParams = new Object[recordInfo.constructor.getParameterTypes().length]; + for (int i = 0; i < columnSize; i++) { + String columnName = series.getColumns().get(i); /*InfluxDB columnName*/ + addParam(clazz, precision, recordInfo, constructorParams, columnName, row.get(i)); + } + // When the "GROUP BY" clause is used, "tags" are returned as Map and + // accordingly with InfluxDB documentation + // https://docs.influxdata.com/influxdb/v1.2/concepts/glossary/#tag-value + // "tag" values are always String. + if (series.getTags() != null) { + for (Entry entry : series.getTags().entrySet()) { + addParam(clazz, precision, recordInfo, constructorParams, entry.getKey()/*InfluxDB columnName*/, + entry.getValue()); } - setFieldValue(object, correspondingField, row.get(i), precision, - classInfo.typeMappers.get(correspondingField)); } + + //noinspection unchecked + result.add((T) recordInfo.constructor.newInstance(constructorParams)); } - // When the "GROUP BY" clause is used, "tags" are returned as Map and - // accordingly with InfluxDB documentation - // https://docs.influxdata.com/influxdb/v1.2/concepts/glossary/#tag-value - // "tag" values are always String. - if (series.getTags() != null && !series.getTags().isEmpty()) { - for (Entry entry : series.getTags().entrySet()) { - Field correspondingField = classInfo.fieldMap.get(entry.getKey()/*InfluxDB columnName*/); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new InfluxDBMapperException(e); + } + } else { + ClassInfo classInfo = CLASS_INFO_CACHE.get(clazz.getName()); + try { + T object = null; + for (List row : series.getValues()) { + for (int i = 0; i < columnSize; i++) { + Field correspondingField = classInfo.fieldMap.get(series.getColumns().get(i)/*InfluxDB columnName*/); if (correspondingField != null) { - // I don't think it is possible to reach here without a valid "object" - setFieldValue(object, correspondingField, entry.getValue(), precision, + if (object == null) { + object = clazz.newInstance(); + } + setFieldValue(object, correspondingField, row.get(i), precision, classInfo.typeMappers.get(correspondingField)); } } + // When the "GROUP BY" clause is used, "tags" are returned as Map and + // accordingly with InfluxDB documentation + // https://docs.influxdata.com/influxdb/v1.2/concepts/glossary/#tag-value + // "tag" values are always String. + if (series.getTags() != null && !series.getTags().isEmpty()) { + for (Entry entry : series.getTags().entrySet()) { + Field correspondingField = classInfo.fieldMap.get(entry.getKey()/*InfluxDB columnName*/); + if (correspondingField != null) { + // I don't think it is possible to reach here without a valid "object" + setFieldValue(object, correspondingField, entry.getValue(), precision, + classInfo.typeMappers.get(correspondingField)); + } + } + } + if (object != null) { + result.add(object); + object = null; + } } - if (object != null) { - result.add(object); - object = null; - } + } catch (InstantiationException | IllegalAccessException e) { + throw new InfluxDBMapperException(e); } - } catch (InstantiationException | IllegalAccessException e) { - throw new InfluxDBMapperException(e); } return result; } + private static void addParam(final Class clazz, final TimeUnit precision, final RecordInfo recordInfo, + final Object[] constructorParams, final String columnName, final Object value) { + Parameter parameter = recordInfo.constructor.getParameters() + [recordInfo.constructorParamIndexes.get(columnName).intValue()]; + constructorParams[recordInfo.constructorParamIndexes.get(columnName).intValue()] = + adaptValue(parameter.getType(), value, precision, parameter.getName(), clazz.getName()); + } + + private static boolean isRecordClass(final Class clazz) { + return RECORD_CLASS_NAMES.contains(clazz.getSuperclass().getName()); + } + /** * InfluxDB client returns any number as Double. * See ... @@ -350,7 +457,7 @@ private static Object adaptValue(final Class fieldType, final Object value, f return Instant.from(RFC3339_FORMATTER.parse(String.valueOf(value))); } if (value instanceof Long) { - return Instant.ofEpochMilli(toMillis((long) value, precision)); + return Instant.ofEpochMilli(toMillis(((Long) value).longValue(), precision)); } if (value instanceof Double) { return Instant.ofEpochMilli(toMillis(((Double) value).longValue(), precision)); @@ -379,8 +486,7 @@ private static Object adaptValue(final Class fieldType, final Object value, f } catch (ClassCastException e) { String msg = "Class '%s' field '%s' was defined with a different field type and caused a ClassCastException. " + "The correct type is '%s' (current field value: '%s')."; - throw new InfluxDBMapperException( - String.format(msg, className, fieldName, value.getClass().getName(), value)); + throw new InfluxDBMapperException(String.format(msg, className, fieldName, value.getClass().getName(), value), e); } throw new InfluxDBMapperException( diff --git a/src/test-jdk17/java/org/influxdb/impl/InfluxDBRecordResultMapperTest.java b/src/test-jdk17/java/org/influxdb/impl/InfluxDBRecordResultMapperTest.java new file mode 100644 index 00000000..c16fa24b --- /dev/null +++ b/src/test-jdk17/java/org/influxdb/impl/InfluxDBRecordResultMapperTest.java @@ -0,0 +1,579 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2017 azeti Networks AG () + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.influxdb.impl; + +import org.influxdb.InfluxDBMapperException; +import org.influxdb.annotation.Column; +import org.influxdb.annotation.Measurement; +import org.influxdb.annotation.TimeColumn; +import org.influxdb.dto.QueryResult; +import org.junit.Assert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.platform.runner.JUnitPlatform; +import org.junit.runner.RunWith; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * @author Eran Leshem + */ +@SuppressWarnings({"removal", "deprecation"}) +@RunWith(JUnitPlatform.class) +public class InfluxDBRecordResultMapperTest { + + private final InfluxDBResultMapper mapper = new InfluxDBResultMapper(); + + @Test + public void testToRecord_HappyPath() { + // Given... + var columnList = Arrays.asList("time", "uuid"); + List firstSeriesResult = Arrays.asList(Instant.now().toEpochMilli(), UUID.randomUUID().toString()); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + series.setName("CustomMeasurement"); + series.setValues(List.of(firstSeriesResult)); + + var internalResult = new QueryResult.Result(); + internalResult.setSeries(List.of(series)); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(internalResult)); + + //When... + var myList = mapper.toPOJO(queryResult, MyCustomMeasurement.class); + + // Then... + Assertions.assertEquals(1, myList.size(), "there must be one entry in the result list"); + } + + @Test + public void testThrowExceptionIfMissingAnnotation() { + Assertions.assertThrows(IllegalArgumentException.class, () -> mapper.throwExceptionIfMissingAnnotation(String.class)); + } + + @Test + public void testThrowExceptionIfError_InfluxQueryResultHasError() { + var queryResult = new QueryResult(); + queryResult.setError("main queryresult error"); + + Assertions.assertThrows(InfluxDBMapperException.class, () -> mapper.throwExceptionIfResultWithError(queryResult)); + } + + @Test + public void testThrowExceptionIfError_InfluxQueryResultSeriesHasError() { + var seriesResult = new QueryResult.Result(); + seriesResult.setError("series error"); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(seriesResult)); + + Assertions.assertThrows(InfluxDBMapperException.class, () -> mapper.throwExceptionIfResultWithError(queryResult)); + } + + @Test + public void testGetMeasurementName_testStateMeasurement() { + Assertions.assertEquals("CustomMeasurement", mapper.getMeasurementName(MyCustomMeasurement.class)); + } + + @Test + public void testParseSeriesAs_testTwoValidSeries() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var columnList = Arrays.asList("time", "uuid"); + + List firstSeriesResult = Arrays.asList(Instant.now().toEpochMilli(), UUID.randomUUID().toString()); + List secondSeriesResult = Arrays.asList(Instant.now().plusSeconds(1).toEpochMilli(), + UUID.randomUUID().toString()); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + series.setValues(Arrays.asList(firstSeriesResult, secondSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 2, "there must be two series in the result list"); + + Assertions.assertEquals(firstSeriesResult.get(0), result.get(0).time().toEpochMilli(), + "Field 'time' (1st series) is not valid"); + Assertions.assertEquals(firstSeriesResult.get(1), result.get(0).uuid(), "Field 'uuid' (1st series) is not valid"); + + Assertions.assertEquals(secondSeriesResult.get(0), result.get(1).time().toEpochMilli(), + "Field 'time' (2nd series) is not valid"); + Assertions.assertEquals(secondSeriesResult.get(1), result.get(1).uuid(), "Field 'uuid' (2nd series) is not valid"); + } + + @Test + public void testParseSeriesAs_testNonNullAndValidValues() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurementWithPrimitives.class); + + var columnList = Arrays.asList("time", "uuid", + "doubleObject", "longObject", "integerObject", + "doublePrimitive", "longPrimitive", "integerPrimitive", + "booleanObject", "booleanPrimitive"); + + // InfluxDB client returns the time representation as Double. + Double now = Long.valueOf(System.currentTimeMillis()).doubleValue(); + + // InfluxDB client returns any number as Double. + // See https://github.com/influxdata/influxdb-java/issues/153#issuecomment-259681987 + // for more information. + + var series = new QueryResult.Series(); + series.setColumns(columnList); + var uuidAsString = UUID.randomUUID().toString(); + List seriesResult = Arrays.asList(now, uuidAsString, + new Double("1.01"), new Double("2"), new Double("3"), + new Double("1.01"), new Double("4"), new Double("5"), + "false", "true"); + series.setValues(List.of(seriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurementWithPrimitives.class, result); + + //Then... + var myObject = result.get(0); + Assertions.assertEquals(now.longValue(), myObject.time().toEpochMilli(), "field 'time' does not match"); + Assertions.assertEquals(uuidAsString, myObject.uuid(), "field 'uuid' does not match"); + + Assertions.assertEquals(asDouble(seriesResult.get(2)), myObject.doubleObject(), + "field 'doubleObject' does not match"); + Assertions.assertEquals(Long.valueOf(asDouble(seriesResult.get(3)).longValue()), myObject.longObject(), + "field 'longObject' does not match"); + Assertions.assertEquals(Integer.valueOf(asDouble(seriesResult.get(4)).intValue()), myObject.integerObject(), + "field 'integerObject' does not match"); + + Assertions.assertTrue( + Double.compare(asDouble(seriesResult.get(5)).doubleValue(), myObject.doublePrimitive()) == 0, + "field 'doublePrimitive' does not match"); + + Assertions.assertTrue(asDouble(seriesResult.get(6)).longValue() == myObject.longPrimitive(), + "field 'longPrimitive' does not match"); + + Assertions.assertTrue(asDouble(seriesResult.get(7)).intValue() == myObject.integerPrimitive(), + "field 'integerPrimitive' does not match"); + + Assertions.assertEquals( + Boolean.valueOf(String.valueOf(seriesResult.get(8))), myObject.booleanObject(), + "field 'booleanObject' does not match"); + + Assertions.assertEquals( + Boolean.valueOf(String.valueOf(seriesResult.get(9))).booleanValue(), myObject.booleanPrimitive(), + "field 'booleanPrimitive' does not match"); + } + + private static Double asDouble(Object obj) { + return (Double) obj; + } + + @Test + public void testFieldValueModified_DateAsISO8601() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var columnList = List.of("time"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = List.of("2017-06-19T09:29:45.655123Z"); + series.setValues(List.of(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testFieldValueModified_DateAsInteger() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var columnList = List.of("time"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = List.of(1_000); + series.setValues(List.of(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testUnsupportedField() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyRecordWithUnsupportedField.class); + + var columnList = List.of("bar"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = List.of("content representing a Date"); + series.setValues(List.of(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + Assertions.assertThrows(InfluxDBMapperException.class, + () -> mapper.parseSeriesAs(series, MyRecordWithUnsupportedField.class, result)); + } + + /** + * for more information. + */ + @Test + public void testToRecord_SeriesFromQueryResultIsNull() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var internalResult = new QueryResult.Result(); + internalResult.setSeries(null); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(internalResult)); + + // When... + var myList = mapper.toPOJO(queryResult, MyCustomMeasurement.class); + + // Then... + Assertions.assertTrue( myList.isEmpty(), "there must NO entry in the result list"); + } + + @Test + public void testToRecord_QueryResultCreatedByGroupByClause() { + // Given... + InfluxDBResultMapper.cacheRecordClass(GroupByCarrierDeviceOS.class); + + // InfluxDB client returns the time representation as Double. + Double now = Long.valueOf(System.currentTimeMillis()).doubleValue(); + + // When the "GROUP BY" clause is used, "tags" are returned as Map + Map firstSeriesTagMap = new HashMap<>(2); + firstSeriesTagMap.put("CARRIER", "000/00"); + firstSeriesTagMap.put("DEVICE_OS_VERSION", "4.4.2"); + + Map secondSeriesTagMap = new HashMap<>(2); + secondSeriesTagMap.put("CARRIER", "000/01"); + secondSeriesTagMap.put("DEVICE_OS_VERSION", "9.3.5"); + + var firstSeries = new QueryResult.Series(); + var columnList = Arrays.asList("time", "median", "min", "max"); + firstSeries.setColumns(columnList); + List firstSeriesResult = Arrays.asList(now, new Double("233.8"), new Double("0.0"), + new Double("3090744.0")); + firstSeries.setValues(List.of(firstSeriesResult)); + firstSeries.setTags(firstSeriesTagMap); + firstSeries.setName("tb_network"); + + var secondSeries = new QueryResult.Series(); + secondSeries.setColumns(columnList); + List secondSeriesResult = Arrays.asList(now, new Double("552.0"), new Double("135.0"), + new Double("267705.0")); + secondSeries.setValues(List.of(secondSeriesResult)); + secondSeries.setTags(secondSeriesTagMap); + secondSeries.setName("tb_network"); + + var internalResult = new QueryResult.Result(); + internalResult.setSeries(Arrays.asList(firstSeries, secondSeries)); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(internalResult)); + + // When... + var myList = mapper.toPOJO(queryResult, GroupByCarrierDeviceOS.class); + + // Then... + var firstGroupByEntry = myList.get(0); + Assertions.assertEquals("000/00", firstGroupByEntry.carrier(), "field 'carrier' does not match"); + Assertions.assertEquals("4.4.2", firstGroupByEntry.deviceOsVersion(), "field 'deviceOsVersion' does not match"); + + var secondGroupByEntry = myList.get(1); + Assertions.assertEquals("000/01", secondGroupByEntry.carrier(), "field 'carrier' does not match"); + Assertions.assertEquals("9.3.5", secondGroupByEntry.deviceOsVersion(), "field 'deviceOsVersion' does not match"); + } + + @Test + public void testToRecord_ticket363() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var columnList = List.of("time"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = List.of("2000-01-01T00:00:00.000000001Z"); + series.setValues(List.of(firstSeriesResult)); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + // Then... + Assertions.assertEquals(1, result.size(), "incorrect number of elemets"); + Assertions.assertEquals(1, result.get(0).time().getNano(), "incorrect value for the nanoseconds field"); + } + + @Test + void testToRecord_Precision() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var series = new QueryResult.Series(); + series.setName("CustomMeasurement"); + var columnList = List.of("time"); + series.setColumns(columnList); + List firstSeriesResult = List.of(1_500_000L); + series.setValues(List.of(firstSeriesResult)); + + var internalResult = new QueryResult.Result(); + internalResult.setSeries(List.of(series)); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(internalResult)); + + // When... + var result = mapper.toPOJO(queryResult, MyCustomMeasurement.class, TimeUnit.SECONDS); + + // Then... + Assertions.assertEquals(1, result.size(), "incorrect number of elements"); + Assertions.assertEquals(1_500_000_000L, result.get(0).time().toEpochMilli(), + "incorrect value for the millis field"); + } + + @Test + void testToRecord_SetMeasureName() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var series = new QueryResult.Series(); + series.setName("MySeriesName"); + var columnList = List.of("uuid"); + series.setColumns(columnList); + List firstSeriesResult = Collections.singletonList(UUID.randomUUID().toString()); + series.setValues(List.of(firstSeriesResult)); + + var internalResult = new QueryResult.Result(); + internalResult.setSeries(List.of(series)); + + var queryResult = new QueryResult(); + queryResult.setResults(List.of(internalResult)); + + //When... + var result = + mapper.toPOJO(queryResult, MyCustomMeasurement.class, "MySeriesName"); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testToRecord_HasTimeColumn() { + // Given... + InfluxDBResultMapper.cacheRecordClass(HasTimeColumnMeasurement.class); + + var columnList = List.of("time"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List> valuesList = Arrays.asList( + List.of("2015-08-17T19:00:00-05:00"), // Chicago (UTC-5) + List.of("2015-08-17T19:00:00.000000001-05:00"), // Chicago (UTC-5) + List.of("2000-01-01T00:00:00-00:00"), + List.of("2000-01-02T00:00:00+00:00") + ); + series.setValues(valuesList); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, HasTimeColumnMeasurement.class, result); + + // Then... + Assertions.assertEquals(4, result.size(), "incorrect number of elemets"); + // Note: RFC3339 timestamp with TZ from InfluxDB are parsed into an Instant (UTC) + Assertions.assertTrue(result.get(0).time().equals(Instant.parse("2015-08-18T00:00:00Z"))); + Assertions.assertTrue(result.get(1).time().equals(Instant.parse("2015-08-18T00:00:00.000000001Z"))); + // RFC3339 section 4.3 https://tools.ietf.org/html/rfc3339#section-4.3 + Assertions.assertTrue(result.get(2).time().equals(Instant.parse("2000-01-01T00:00:00Z"))); + Assertions.assertTrue(result.get(3).time().equals(Instant.parse("2000-01-02T00:00:00Z"))); + + } + + @Test + public void testToRecord_ticket573() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + var columnList = List.of("time"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List> valuesList = Arrays.asList( + List.of("2015-08-17T19:00:00-05:00"), // Chicago (UTC-5) + List.of("2015-08-17T19:00:00.000000001-05:00"), // Chicago (UTC-5) + List.of("2000-01-01T00:00:00-00:00"), + List.of("2000-01-02T00:00:00+00:00") + ); + series.setValues(valuesList); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + // Then... + Assertions.assertEquals(4, result.size(), "incorrect number of elemets"); + // Note: RFC3339 timestamp with TZ from InfluxDB are parsed into an Instant (UTC) + Assertions.assertTrue(result.get(0).time().equals(Instant.parse("2015-08-18T00:00:00Z"))); + Assertions.assertTrue(result.get(1).time().equals(Instant.parse("2015-08-18T00:00:00.000000001Z"))); + // RFC3339 section 4.3 https://tools.ietf.org/html/rfc3339#section-4.3 + Assertions.assertTrue(result.get(2).time().equals(Instant.parse("2000-01-01T00:00:00Z"))); + Assertions.assertTrue(result.get(3).time().equals(Instant.parse("2000-01-02T00:00:00Z"))); + } + + @Test + public void testMultipleConstructors() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MultipleConstructors.class); + + var columnList = List.of("i", "s"); + + var series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = List.of(9.0, "str"); + series.setValues(List.of(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MultipleConstructors.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + + Assert.assertEquals(9, result.get(0).i()); + Assert.assertEquals("str", result.get(0).s()); + } + + @Test + public void testConflictingConstructors() { + Assert.assertThrows(InfluxDBMapperException.class, + () -> InfluxDBResultMapper.cacheRecordClass(ConflictingConstructors.class)); + } + + @Measurement(name = "HasTimeColumnMeasurement") + record HasTimeColumnMeasurement( + @TimeColumn + Instant time, + Integer value) {} + + @Measurement(name = "CustomMeasurement") + record MyCustomMeasurement( + Instant time, + String uuid, + Double doubleObject, + Long longObject, + Integer integerObject, + Boolean booleanObject, + + @SuppressWarnings("unused") + String nonColumn1, + + @SuppressWarnings("unused") + Random rnd) {} + + @Measurement(name = "CustomMeasurement") + record MyCustomMeasurementWithPrimitives( + Instant time, + String uuid, + Double doubleObject, + Long longObject, + Integer integerObject, + double doublePrimitive, + long longPrimitive, + int integerPrimitive, + Boolean booleanObject, + boolean booleanPrimitive, + + @SuppressWarnings("unused") + String nonColumn1, + + @SuppressWarnings("unused") + Random rnd) {} + + @Measurement(name = "foo") + record MyRecordWithUnsupportedField( + @Column(name = "bar") + Date myDate) {} + + /** + * Class created based on example from this issue + */ + @Measurement(name = "tb_network") + record GroupByCarrierDeviceOS( + Instant time, + + @Column(name = "CARRIER", tag = true) + String carrier, + + @Column(name = "DEVICE_OS_VERSION", tag = true) + String deviceOsVersion, + + Double median, + Double min, + Double max) {} + + record MultipleConstructors(int i, String s) { + MultipleConstructors(String i, String s) { + this(Integer.parseInt(i), s); + } + + MultipleConstructors(int i, String s, double d) { + this(i, s); + } + } + + record ConflictingConstructors(int i, String s) { + private ConflictingConstructors(String s, int i) { + this(i, s); + } + } +} diff --git a/src/test/java/com/android/tools/r8/RecordTag.java b/src/test/java/com/android/tools/r8/RecordTag.java new file mode 100644 index 00000000..51aacedb --- /dev/null +++ b/src/test/java/com/android/tools/r8/RecordTag.java @@ -0,0 +1,9 @@ +package com.android.tools.r8; + +/** + * Simulates the super class of Android-desugared records. + * + * @author Eran Leshem + **/ +public class RecordTag { +} diff --git a/src/test/java/org/influxdb/impl/InfluxDBAndroidDesugaredRecordResultMapperTest.java b/src/test/java/org/influxdb/impl/InfluxDBAndroidDesugaredRecordResultMapperTest.java new file mode 100644 index 00000000..f1dc21dc --- /dev/null +++ b/src/test/java/org/influxdb/impl/InfluxDBAndroidDesugaredRecordResultMapperTest.java @@ -0,0 +1,819 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2017 azeti Networks AG () + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.influxdb.impl; + +import com.android.tools.r8.RecordTag; +import org.influxdb.InfluxDBMapperException; +import org.influxdb.annotation.Column; +import org.influxdb.annotation.Measurement; +import org.influxdb.annotation.TimeColumn; +import org.influxdb.dto.QueryResult; +import org.junit.Assert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.platform.runner.JUnitPlatform; +import org.junit.runner.RunWith; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Test measurement classes simulate Android desugared records. + * + * @author Eran Leshem + */ +@SuppressWarnings({"removal", "deprecation"}) +@RunWith(JUnitPlatform.class) +public class InfluxDBAndroidDesugaredRecordResultMapperTest { + + private final InfluxDBResultMapper mapper = new InfluxDBResultMapper(); + + @Test + public void testToRecord_HappyPath() { + // Given... + List columnList = Arrays.asList("time", "uuid"); + List firstSeriesResult = Arrays.asList(Instant.now().toEpochMilli(), UUID.randomUUID().toString()); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + series.setName("CustomMeasurement"); + series.setValues(Arrays.asList(firstSeriesResult)); + + QueryResult.Result internalResult = new QueryResult.Result(); + internalResult.setSeries(Arrays.asList(series)); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(internalResult)); + + //When... + List myList = mapper.toPOJO(queryResult, MyCustomMeasurement.class); + + // Then... + Assertions.assertEquals(1, myList.size(), "there must be one entry in the result list"); + } + + @Test + public void testThrowExceptionIfMissingAnnotation() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> mapper.throwExceptionIfMissingAnnotation(String.class)); + } + + @Test + public void testThrowExceptionIfError_InfluxQueryResultHasError() { + QueryResult queryResult = new QueryResult(); + queryResult.setError("main queryresult error"); + + Assertions.assertThrows(InfluxDBMapperException.class, () -> mapper.throwExceptionIfResultWithError(queryResult)); + } + + @Test + public void testThrowExceptionIfError_InfluxQueryResultSeriesHasError() { + QueryResult.Result seriesResult = new QueryResult.Result(); + seriesResult.setError("series error"); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(seriesResult)); + + Assertions.assertThrows(InfluxDBMapperException.class, () -> mapper.throwExceptionIfResultWithError(queryResult)); + } + + @Test + public void testGetMeasurementName_testStateMeasurement() { + Assertions.assertEquals("CustomMeasurement", mapper.getMeasurementName(MyCustomMeasurement.class)); + } + + @Test + public void testParseSeriesAs_testTwoValidSeries() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + List columnList = Arrays.asList("time", "uuid"); + + List firstSeriesResult = Arrays.asList(Instant.now().toEpochMilli(), UUID.randomUUID().toString()); + List secondSeriesResult = Arrays.asList(Instant.now().plusSeconds(1).toEpochMilli(), + UUID.randomUUID().toString()); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + series.setValues(Arrays.asList(firstSeriesResult, secondSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 2, "there must be two series in the result list"); + + Assertions.assertEquals(firstSeriesResult.get(0), result.get(0).time().toEpochMilli(), + "Field 'time' (1st series) is not valid"); + Assertions.assertEquals(firstSeriesResult.get(1), result.get(0).uuid(), "Field 'uuid' (1st series) is not valid"); + + Assertions.assertEquals(secondSeriesResult.get(0), result.get(1).time().toEpochMilli(), + "Field 'time' (2nd series) is not valid"); + Assertions.assertEquals(secondSeriesResult.get(1), result.get(1).uuid(), "Field 'uuid' (2nd series) is not valid"); + } + + @Test + public void testParseSeriesAs_testNonNullAndValidValues() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurementWithPrimitives.class); + + List columnList = Arrays.asList("time", "uuid", + "doubleObject", "longObject", "integerObject", + "doublePrimitive", "longPrimitive", "integerPrimitive", + "booleanObject", "booleanPrimitive"); + + // InfluxDB client returns the time representation as Double. + Double now = Long.valueOf(System.currentTimeMillis()).doubleValue(); + + // InfluxDB client returns any number as Double. + // See https://github.com/influxdata/influxdb-java/issues/153#issuecomment-259681987 + // for more information. + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + String uuidAsString = UUID.randomUUID().toString(); + List seriesResult = Arrays.asList(now, uuidAsString, + new Double("1.01"), new Double("2"), new Double("3"), + new Double("1.01"), new Double("4"), new Double("5"), + "false", "true"); + series.setValues(Arrays.asList(seriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurementWithPrimitives.class, result); + + //Then... + MyCustomMeasurementWithPrimitives myObject = result.get(0); + Assertions.assertEquals(now.longValue(), myObject.time().toEpochMilli(), "field 'time' does not match"); + Assertions.assertEquals(uuidAsString, myObject.uuid(), "field 'uuid' does not match"); + + Assertions.assertEquals(asDouble(seriesResult.get(2)), myObject.doubleObject(), + "field 'doubleObject' does not match"); + Assertions.assertEquals(Long.valueOf(asDouble(seriesResult.get(3)).longValue()), myObject.longObject(), + "field 'longObject' does not match"); + Assertions.assertEquals(Integer.valueOf(asDouble(seriesResult.get(4)).intValue()), myObject.integerObject(), + "field 'integerObject' does not match"); + + Assertions.assertTrue( + Double.compare(asDouble(seriesResult.get(5)).doubleValue(), myObject.doublePrimitive()) == 0, + "field 'doublePrimitive' does not match"); + + Assertions.assertTrue(asDouble(seriesResult.get(6)).longValue() == myObject.longPrimitive(), + "field 'longPrimitive' does not match"); + + Assertions.assertTrue(asDouble(seriesResult.get(7)).intValue() == myObject.integerPrimitive(), + "field 'integerPrimitive' does not match"); + + Assertions.assertEquals( + Boolean.valueOf(String.valueOf(seriesResult.get(8))), myObject.booleanObject(), + "field 'booleanObject' does not match"); + + Assertions.assertEquals( + Boolean.valueOf(String.valueOf(seriesResult.get(9))).booleanValue(), myObject.booleanPrimitive(), + "field 'booleanPrimitive' does not match"); + } + + private static Double asDouble(Object obj) { + return (Double) obj; + } + + @Test + public void testFieldValueModified_DateAsISO8601() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + List columnList = Arrays.asList("time"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList("2017-06-19T09:29:45.655123Z"); + series.setValues(Arrays.asList(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testFieldValueModified_DateAsInteger() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + List columnList = Arrays.asList("time"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList(1_000); + series.setValues(Arrays.asList(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testUnsupportedField() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyRecordWithUnsupportedField.class); + + List columnList = Arrays.asList("bar"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList("content representing a Date"); + series.setValues(Arrays.asList(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + Assertions.assertThrows(InfluxDBMapperException.class, + () -> mapper.parseSeriesAs(series, MyRecordWithUnsupportedField.class, result)); + } + + /** + * for more information. + */ + @Test + public void testToRecord_SeriesFromQueryResultIsNull() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + QueryResult.Result internalResult = new QueryResult.Result(); + internalResult.setSeries(null); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(internalResult)); + + // When... + List myList = mapper.toPOJO(queryResult, MyCustomMeasurement.class); + + // Then... + Assertions.assertTrue(myList.isEmpty(), "there must NO entry in the result list"); + } + + @Test + public void testToRecord_QueryResultCreatedByGroupByClause() { + // Given... + InfluxDBResultMapper.cacheRecordClass(GroupByCarrierDeviceOS.class); + + // InfluxDB client returns the time representation as Double. + Double now = Long.valueOf(System.currentTimeMillis()).doubleValue(); + + // When the "GROUP BY" clause is used, "tags" are returned as Map + Map firstSeriesTagMap = new HashMap<>(2); + firstSeriesTagMap.put("CARRIER", "000/00"); + firstSeriesTagMap.put("DEVICE_OS_VERSION", "4.4.2"); + + Map secondSeriesTagMap = new HashMap<>(2); + secondSeriesTagMap.put("CARRIER", "000/01"); + secondSeriesTagMap.put("DEVICE_OS_VERSION", "9.3.5"); + + QueryResult.Series firstSeries = new QueryResult.Series(); + List columnList = Arrays.asList("time", "median", "min", "max"); + firstSeries.setColumns(columnList); + List firstSeriesResult = Arrays.asList(now, new Double("233.8"), new Double("0.0"), + new Double("3090744.0")); + firstSeries.setValues(Arrays.asList(firstSeriesResult)); + firstSeries.setTags(firstSeriesTagMap); + firstSeries.setName("tb_network"); + + QueryResult.Series secondSeries = new QueryResult.Series(); + secondSeries.setColumns(columnList); + List secondSeriesResult = Arrays.asList(now, new Double("552.0"), new Double("135.0"), + new Double("267705.0")); + secondSeries.setValues(Arrays.asList(secondSeriesResult)); + secondSeries.setTags(secondSeriesTagMap); + secondSeries.setName("tb_network"); + + QueryResult.Result internalResult = new QueryResult.Result(); + internalResult.setSeries(Arrays.asList(firstSeries, secondSeries)); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(internalResult)); + + // When... + List myList = mapper.toPOJO(queryResult, GroupByCarrierDeviceOS.class); + + // Then... + GroupByCarrierDeviceOS firstGroupByEntry = myList.get(0); + Assertions.assertEquals("000/00", firstGroupByEntry.carrier(), "field 'carrier' does not match"); + Assertions.assertEquals("4.4.2", firstGroupByEntry.deviceOsVersion(), "field 'deviceOsVersion' does not match"); + + GroupByCarrierDeviceOS secondGroupByEntry = myList.get(1); + Assertions.assertEquals("000/01", secondGroupByEntry.carrier(), "field 'carrier' does not match"); + Assertions.assertEquals("9.3.5", secondGroupByEntry.deviceOsVersion(), "field 'deviceOsVersion' does not match"); + } + + @Test + public void testToRecord_ticket363() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + List columnList = Arrays.asList("time"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList("2000-01-01T00:00:00.000000001Z"); + series.setValues(Arrays.asList(firstSeriesResult)); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + // Then... + Assertions.assertEquals(1, result.size(), "incorrect number of elemets"); + Assertions.assertEquals(1, result.get(0).time().getNano(), "incorrect value for the nanoseconds field"); + } + + @Test + void testToRecord_Precision() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + QueryResult.Series series = new QueryResult.Series(); + series.setName("CustomMeasurement"); + List columnList = Arrays.asList("time"); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList(1_500_000L); + series.setValues(Arrays.asList(firstSeriesResult)); + + QueryResult.Result internalResult = new QueryResult.Result(); + internalResult.setSeries(Arrays.asList(series)); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(internalResult)); + + // When... + List result = mapper.toPOJO(queryResult, MyCustomMeasurement.class, TimeUnit.SECONDS); + + // Then... + Assertions.assertEquals(1, result.size(), "incorrect number of elements"); + Assertions.assertEquals(1_500_000_000L, result.get(0).time().toEpochMilli(), + "incorrect value for the millis field"); + } + + @Test + void testToRecord_SetMeasureName() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + QueryResult.Series series = new QueryResult.Series(); + series.setName("MySeriesName"); + List columnList = Arrays.asList("uuid"); + series.setColumns(columnList); + List firstSeriesResult = Collections.singletonList(UUID.randomUUID().toString()); + series.setValues(Arrays.asList(firstSeriesResult)); + + QueryResult.Result internalResult = new QueryResult.Result(); + internalResult.setSeries(Arrays.asList(series)); + + QueryResult queryResult = new QueryResult(); + queryResult.setResults(Arrays.asList(internalResult)); + + //When... + List result = + mapper.toPOJO(queryResult, MyCustomMeasurement.class, "MySeriesName"); + + //Then... + Assertions.assertTrue(result.size() == 1); + } + + @Test + public void testToRecord_HasTimeColumn() { + // Given... + InfluxDBResultMapper.cacheRecordClass(HasTimeColumnMeasurement.class); + + List columnList = Arrays.asList("time"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List> valuesList = Arrays.asList( + Arrays.asList("2015-08-17T19:00:00-05:00"), // Chicago (UTC-5) + Arrays.asList("2015-08-17T19:00:00.000000001-05:00"), // Chicago (UTC-5) + Arrays.asList("2000-01-01T00:00:00-00:00"), + Arrays.asList("2000-01-02T00:00:00+00:00") + ); + series.setValues(valuesList); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, HasTimeColumnMeasurement.class, result); + + // Then... + Assertions.assertEquals(4, result.size(), "incorrect number of elemets"); + // Note: RFC3339 timestamp with TZ from InfluxDB are parsed into an Instant (UTC) + Assertions.assertTrue(result.get(0).time().equals(Instant.parse("2015-08-18T00:00:00Z"))); + Assertions.assertTrue(result.get(1).time().equals(Instant.parse("2015-08-18T00:00:00.000000001Z"))); + // RFC3339 section 4.3 https://tools.ietf.org/html/rfc3339#section-4.3 + Assertions.assertTrue(result.get(2).time().equals(Instant.parse("2000-01-01T00:00:00Z"))); + Assertions.assertTrue(result.get(3).time().equals(Instant.parse("2000-01-02T00:00:00Z"))); + + } + + @Test + public void testToRecord_ticket573() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MyCustomMeasurement.class); + + List columnList = Arrays.asList("time"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List> valuesList = Arrays.asList( + Arrays.asList("2015-08-17T19:00:00-05:00"), // Chicago (UTC-5) + Arrays.asList("2015-08-17T19:00:00.000000001-05:00"), // Chicago (UTC-5) + Arrays.asList("2000-01-01T00:00:00-00:00"), + Arrays.asList("2000-01-02T00:00:00+00:00") + ); + series.setValues(valuesList); + + // When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MyCustomMeasurement.class, result); + + // Then... + Assertions.assertEquals(4, result.size(), "incorrect number of elemets"); + // Note: RFC3339 timestamp with TZ from InfluxDB are parsed into an Instant (UTC) + Assertions.assertTrue(result.get(0).time().equals(Instant.parse("2015-08-18T00:00:00Z"))); + Assertions.assertTrue(result.get(1).time().equals(Instant.parse("2015-08-18T00:00:00.000000001Z"))); + // RFC3339 section 4.3 https://tools.ietf.org/html/rfc3339#section-4.3 + Assertions.assertTrue(result.get(2).time().equals(Instant.parse("2000-01-01T00:00:00Z"))); + Assertions.assertTrue(result.get(3).time().equals(Instant.parse("2000-01-02T00:00:00Z"))); + } + + @Test + public void testMultipleConstructors() { + // Given... + InfluxDBResultMapper.cacheRecordClass(MultipleConstructors.class); + + List columnList = Arrays.asList("i", "s"); + + QueryResult.Series series = new QueryResult.Series(); + series.setColumns(columnList); + List firstSeriesResult = Arrays.asList(9.0, "str"); + series.setValues(Arrays.asList(firstSeriesResult)); + + //When... + List result = new LinkedList<>(); + mapper.parseSeriesAs(series, MultipleConstructors.class, result); + + //Then... + Assertions.assertTrue(result.size() == 1); + + Assert.assertEquals(9, result.get(0).i()); + Assert.assertEquals("str", result.get(0).s()); + } + + @Test + public void testConflictingConstructors() { + Assert.assertThrows(InfluxDBMapperException.class, + () -> InfluxDBResultMapper.cacheRecordClass(ConflictingConstructors.class)); + } + + @Measurement(name = "HasTimeColumnMeasurement") + static final class HasTimeColumnMeasurement extends RecordTag { + @TimeColumn + private final Instant time; + private final Integer value; + + HasTimeColumnMeasurement(Instant time, Integer value) { + this.time = time; + this.value = value; + } + + public Instant time() { + return time; + } + + public Integer value() { + return value; + } + } + + @Measurement(name = "CustomMeasurement") + static final class MyCustomMeasurement extends RecordTag { + private final Instant time; + private final String uuid; + private final Double doubleObject; + private final Long longObject; + private final Integer integerObject; + private final Boolean booleanObject; + + @SuppressWarnings("unused") + private final String nonColumn1; + + @SuppressWarnings("unused") + private final Random rnd; + + MyCustomMeasurement( + Instant time, + String uuid, + Double doubleObject, + Long longObject, + Integer integerObject, + Boolean booleanObject, + + @SuppressWarnings("unused") + String nonColumn1, + + @SuppressWarnings("unused") + Random rnd) { + this.time = time; + this.uuid = uuid; + this.doubleObject = doubleObject; + this.longObject = longObject; + this.integerObject = integerObject; + this.booleanObject = booleanObject; + this.nonColumn1 = nonColumn1; + this.rnd = rnd; + } + + public Instant time() { + return time; + } + + public String uuid() { + return uuid; + } + + public Double doubleObject() { + return doubleObject; + } + + public Long longObject() { + return longObject; + } + + public Integer integerObject() { + return integerObject; + } + + public Boolean booleanObject() { + return booleanObject; + } + + @SuppressWarnings("unused") + public String nonColumn1() { + return nonColumn1; + } + + @SuppressWarnings("unused") + public Random rnd() { + return rnd; + } + } + + @Measurement(name = "CustomMeasurement") + static final class MyCustomMeasurementWithPrimitives extends RecordTag { + private final Instant time; + private final String uuid; + private final Double doubleObject; + private final Long longObject; + private final Integer integerObject; + private final double doublePrimitive; + private final long longPrimitive; + private final int integerPrimitive; + private final Boolean booleanObject; + private final boolean booleanPrimitive; + + @SuppressWarnings("unused") + private final String nonColumn1; + + @SuppressWarnings("unused") + private final Random rnd; + + MyCustomMeasurementWithPrimitives( + Instant time, + String uuid, + Double doubleObject, + Long longObject, + Integer integerObject, + double doublePrimitive, + long longPrimitive, + int integerPrimitive, + Boolean booleanObject, + boolean booleanPrimitive, + + @SuppressWarnings("unused") + String nonColumn1, + + @SuppressWarnings("unused") + Random rnd) { + this.time = time; + this.uuid = uuid; + this.doubleObject = doubleObject; + this.longObject = longObject; + this.integerObject = integerObject; + this.doublePrimitive = doublePrimitive; + this.longPrimitive = longPrimitive; + this.integerPrimitive = integerPrimitive; + this.booleanObject = booleanObject; + this.booleanPrimitive = booleanPrimitive; + this.nonColumn1 = nonColumn1; + this.rnd = rnd; + } + + public Instant time() { + return time; + } + + public String uuid() { + return uuid; + } + + public Double doubleObject() { + return doubleObject; + } + + public Long longObject() { + return longObject; + } + + public Integer integerObject() { + return integerObject; + } + + public double doublePrimitive() { + return doublePrimitive; + } + + public long longPrimitive() { + return longPrimitive; + } + + public int integerPrimitive() { + return integerPrimitive; + } + + public Boolean booleanObject() { + return booleanObject; + } + + public boolean booleanPrimitive() { + return booleanPrimitive; + } + + @SuppressWarnings("unused") + public String nonColumn1() { + return nonColumn1; + } + + @SuppressWarnings("unused") + public Random rnd() { + return rnd; + } + } + + @Measurement(name = "foo") + static final class MyRecordWithUnsupportedField extends RecordTag { + @Column(name = "bar") + private final Date myDate; + + MyRecordWithUnsupportedField(Date myDate) { + this.myDate = myDate; + } + + public Date myDate() { + return myDate; + } + } + + /** + * Class created based on example from this issue + */ + @Measurement(name = "tb_network") + static final class GroupByCarrierDeviceOS extends RecordTag { + private final Instant time; + + @Column(name = "CARRIER", tag = true) + private final String carrier; + + @Column(name = "DEVICE_OS_VERSION", tag = true) + private final String deviceOsVersion; + + private final Double median; + private final Double min; + private final Double max; + + GroupByCarrierDeviceOS( + Instant time, + String carrier, + String deviceOsVersion, + Double median, + Double min, + Double max) { + this.time = time; + this.carrier = carrier; + this.deviceOsVersion = deviceOsVersion; + this.median = median; + this.min = min; + this.max = max; + } + + public Instant time() { + return time; + } + + public String carrier() { + return carrier; + } + + public String deviceOsVersion() { + return deviceOsVersion; + } + + public Double median() { + return median; + } + + public Double min() { + return min; + } + + public Double max() { + return max; + } + } + + static final class MultipleConstructors extends RecordTag { + private final int i; + private final String s; + + MultipleConstructors(int i, String s) { + this.i = i; + this.s = s; + } + + MultipleConstructors(String i, String s) { + this(Integer.parseInt(i), s); + } + + MultipleConstructors(int i, String s, double d) { + this(i, s); + } + + int i() { + return i; + } + + String s() { + return s; + } + } + + + static final class ConflictingConstructors extends RecordTag { + private final int i; + private final String s; + + private ConflictingConstructors(int i, String s) { + this.i = i; + this.s = s; + } + + private ConflictingConstructors(String s, int i) { + this(i, s); + } + + public int i() { + return i; + } + + public String s() { + return s; + } + } +} \ No newline at end of file