diff --git a/InfluxData.Net.Common/Attributes/FieldAttribute.cs b/InfluxData.Net.Common/Attributes/FieldAttribute.cs new file mode 100644 index 0000000..a74821a --- /dev/null +++ b/InfluxData.Net.Common/Attributes/FieldAttribute.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace InfluxData.Net.Common.Attributes +{ + public class FieldAttribute : InfluxBaseAttribute + { + public FieldAttribute([CallerMemberName]string name = null) + : base(name) { } + } +} diff --git a/InfluxData.Net.Common/Attributes/InfluxBaseAttribute.cs b/InfluxData.Net.Common/Attributes/InfluxBaseAttribute.cs new file mode 100644 index 0000000..9741038 --- /dev/null +++ b/InfluxData.Net.Common/Attributes/InfluxBaseAttribute.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace InfluxData.Net.Common.Attributes +{ + public class InfluxBaseAttribute : Attribute + { + + public InfluxBaseAttribute() { } + + public InfluxBaseAttribute(string name) + { + Name = name; + } + + public string Name { get; set; } + } +} diff --git a/InfluxData.Net.Common/Attributes/MeasurementAttribute.cs b/InfluxData.Net.Common/Attributes/MeasurementAttribute.cs new file mode 100644 index 0000000..86c7b6f --- /dev/null +++ b/InfluxData.Net.Common/Attributes/MeasurementAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace InfluxData.Net.Common.Attributes +{ + public class MeasurementAttribute : InfluxBaseAttribute + { + } +} diff --git a/InfluxData.Net.Common/Attributes/TagAttribute.cs b/InfluxData.Net.Common/Attributes/TagAttribute.cs new file mode 100644 index 0000000..19e4ec6 --- /dev/null +++ b/InfluxData.Net.Common/Attributes/TagAttribute.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace InfluxData.Net.Common.Attributes +{ + public class TagAttribute : InfluxBaseAttribute + { + public TagAttribute([CallerMemberName]string name = null) + : base(name) { } + } +} diff --git a/InfluxData.Net.Common/Attributes/TimestampAttribute.cs b/InfluxData.Net.Common/Attributes/TimestampAttribute.cs new file mode 100644 index 0000000..eae1d85 --- /dev/null +++ b/InfluxData.Net.Common/Attributes/TimestampAttribute.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace InfluxData.Net.Common.Attributes +{ + public class TimestampAttribute : InfluxBaseAttribute + { + public TimestampAttribute() + : base("time") { } + } +} diff --git a/InfluxData.Net.Common/Infrastructure/MissingExpectedAttributeException.cs b/InfluxData.Net.Common/Infrastructure/MissingExpectedAttributeException.cs new file mode 100644 index 0000000..6af0abb --- /dev/null +++ b/InfluxData.Net.Common/Infrastructure/MissingExpectedAttributeException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace InfluxData.Net.Common.Infrastructure +{ + public class MissingExpectedAttributeException : Exception + { + public MissingExpectedAttributeException(Type attributeType) + : base($"The expected attribute: {attributeType.Name} is missing") + { + } + } +} diff --git a/InfluxData.Net.InfluxDb/Helpers/PointExtensions.cs b/InfluxData.Net.InfluxDb/Helpers/PointExtensions.cs new file mode 100644 index 0000000..83f5a0a --- /dev/null +++ b/InfluxData.Net.InfluxDb/Helpers/PointExtensions.cs @@ -0,0 +1,187 @@ +using InfluxData.Net.Common.Attributes; +using InfluxData.Net.InfluxDb.Models; +using System; +using System.Collections.Generic; +using System.Text; +using System.Linq; +using InfluxData.Net.Common.Infrastructure; +using System.Reflection; + +namespace InfluxData.Net.InfluxDb.Helpers +{ + public static class PointExtensions + { + /// + /// Allows for converting attribute decorated types into a + /// Attribute rules: + /// 1) Must have exactly ONE [Measurement] attribute + /// 2) Must not have more than ONE [Timestamp] attribute + /// 3) Must have at least ONE [Field] attribute + /// 4) [Tag] attribute is optional + /// + /// + /// + /// + /// + /// Example of valid type: + /// + /// + /// public class MyType + /// { + /// [Measurement] + /// public string MyMeasurement { get; set; } + /// + /// [Timestamp] + /// public DateTime Time { get; set; } + /// + /// [Tag] + /// public string SignalName { get; set; } + /// + /// [Field] + /// public double Value { get; set; } + /// } + /// + /// + /// + public static Point ToPoint(this TModel model) + { + var type = model.GetType(); + + Point point = new Point(); + + var properties = type.GetProperties(); + + point.TrySetTimestamp(model, properties); + point.TrySetMeasurement(model, properties); + point.TrySetTags(model, properties); + point.TrySetFields(model, properties); + + return point; + } + + private static Point TrySetTimestamp(this Point point, TModel model, PropertyInfo[] properties) + { + var timestampProperties = properties.Where(x => x.IsDefined(typeof(TimestampAttribute), false)); + + // Make sure only one TimestampAttribute is defined + if (timestampProperties.Any()) + { + if (timestampProperties.Count() != 1) + throw new InvalidOperationException($"Cannot have multiple {typeof(TimestampAttribute).Name} attributes defined"); + + var timestampProperty = timestampProperties.FirstOrDefault(); + var timestampPropertyValue = timestampProperty.GetValue(model); + + if (!timestampProperty.PropertyType.Equals(typeof(DateTime))) + throw new InvalidOperationException($"{nameof(timestampProperty.Name)} is not of type {typeof(DateTime).Name}"); + + if (timestampPropertyValue == null) + throw new InvalidOperationException($"{nameof(timestampProperty.Name)} cannot be null"); + + point.Timestamp = (DateTime)timestampPropertyValue; + } + + return point; + } + + private static Point TrySetMeasurement(this Point point, TModel model, PropertyInfo[] properties) + { + var measurementProperties = properties.Where(x => x.IsDefined(typeof(MeasurementAttribute), false)); + + if(!measurementProperties.Any()) + { + throw new MissingExpectedAttributeException(typeof(MeasurementAttribute)); + } + + // Make sure only one MeasurementAttribute is defined + if (measurementProperties.Count() != 1) + { + throw new InvalidOperationException($"Must have exactly one {typeof(MeasurementAttribute).Name} attribute defined"); + } + + var measurementProperty = measurementProperties.FirstOrDefault(); + var measurementPropertyValue = measurementProperty.GetValue(model); + + if (!measurementProperty.PropertyType.Equals(typeof(string))) + { + throw new InvalidOperationException($"{nameof(measurementProperty.Name)} is not of type {typeof(string).Name}"); + } + + if ((string.IsNullOrWhiteSpace((string)measurementPropertyValue))) + { + throw new InvalidOperationException($"{nameof(measurementProperty.Name)} cannot be null or whitespace"); + } + + point.Name = (string)measurementPropertyValue; + + return point; + } + + private static Point TrySetTags(this Point point, TModel model, PropertyInfo[] properties) + { + var tagProperties = properties.Where(x => x.IsDefined(typeof(TagAttribute), false)); + + if (tagProperties.Any(x => !x.PropertyType.Equals(typeof(string)))) + { + throw new InvalidOperationException($"Tags can only be string values"); + } + + foreach (var tagProperty in tagProperties) + { + var tagType = tagProperty.PropertyType; + var tagValue = tagProperty.GetValue(model); + + if (tagValue == null) + continue; + + var converted = Convert.ChangeType(tagValue, tagType); + + var propertyName = tagProperty.GetCustomAttribute().Name; + + point.Tags.Add(propertyName, converted); + } + + return point; + } + + private static Point TrySetFields(this Point point, TModel model, PropertyInfo[] properties) + { + var fieldProperties = properties.Where(x => x.IsDefined(typeof(FieldAttribute), false)); + + // Make sure at least one FieldAttribute is defined + if (!fieldProperties.Any()) + { + throw new MissingExpectedAttributeException(typeof(FieldAttribute)); + } + + if (fieldProperties.Any(x => !x.PropertyType.IsSimple())) + { + throw new InvalidOperationException($"Fields can only be primitive or string values"); + } + + foreach (var fieldProperty in fieldProperties) + { + var fieldType = fieldProperty.PropertyType; + var fieldValue = fieldProperty.GetValue(model); + + if (fieldValue == null) + continue; + + var converted = Convert.ChangeType(fieldValue, fieldType); + + var propertyName = fieldProperty.GetCustomAttribute().Name; + + point.Fields.Add(propertyName, converted); + } + + return point; + } + + private static bool IsSimple(this Type type) + { + return + type.IsPrimitive || + type.Equals(typeof(String)); + } + } +} diff --git a/InfluxData.Net.Tests/InfluxDb/Helpers/PointExtensionsTests.cs b/InfluxData.Net.Tests/InfluxDb/Helpers/PointExtensionsTests.cs new file mode 100644 index 0000000..fc57d60 --- /dev/null +++ b/InfluxData.Net.Tests/InfluxDb/Helpers/PointExtensionsTests.cs @@ -0,0 +1,284 @@ +using InfluxData.Net.Common.Attributes; +using InfluxData.Net.Common.Infrastructure; +using InfluxData.Net.InfluxDb.Helpers; +using InfluxData.Net.InfluxDb.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Xunit; + +namespace InfluxData.Net.Tests.InfluxDb.Helpers +{ + [Trait("InfluxDb Helpers", "Point extensions")] + public class PointExtensionsTests + { + [Fact] + public void ToPoint_OnMissingMeasurementAttribute_ThrowsException() + { + TestModelWithoutMeasurement model = new TestModelWithoutMeasurement + { + Time = DateTime.Now, + TestTag = "TestTag", + TestField = "TestField" + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnMissingFieldAttribute_ThrowsException() + { + TestModelWithoutFields model = new TestModelWithoutFields + { + Time = DateTime.Now, + MyMeasurement = "FakeMeasurement", + TestTag = "TestTag" + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnMultipleMeasurementAttributes_ThrowsException() + { + TestModelWithMultipleMeasurements model = new TestModelWithMultipleMeasurements + { + Time = DateTime.Now, + Measurement1 = "FakeMeasurement1", + Measurement2 = "FakeMeasurement2", + TestTag = "TestTag", + TestField = "TestField" + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnMultipleTimestampAttributes_ThrowsException() + { + TestModelWithMultipleTimestamps model = new TestModelWithMultipleTimestamps + { + Time1 = DateTime.Now, + Time2 = DateTime.Now, + Measurement = "FakeMeasurement", + TestTag = "TestTag", + TestField = "TestField" + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnNonPrimitiveTagProperties_ThrowsException() + { + TestModelWithNonStringTags model = new TestModelWithNonStringTags + { + Measurement = "FakeMeasurement", + Time = DateTime.Now, + PrimitiveTag = "PrimitiveTag", + NonPrimitiveTag = new NonPrimitiveType { Whatever = "Whatever", Whatever2 = "Whatever" }, + PrimitiveField = "PrimitiveField" + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnNonPrimitiveFieldProperties_ThrowsException() + { + TestModelWithNonPrimitiveFields model = new TestModelWithNonPrimitiveFields + { + Measurement = "FakeMeasurement", + Time = DateTime.Now, + PrimitiveTag = "PrimitiveTag", + PrimitiveField = "PrimitiveField", + NonPrimitiveField = new NonPrimitiveType { Whatever = "Whatever", Whatever2 = "Whatever" } + }; + + Assert.Throws(() => model.ToPoint()); + } + + [Fact] + public void ToPoint_OnValidType_IsConverted() + { + var measurement = "FakeMeasurement"; + var time = DateTime.Now; + + var firstTagValue = "FirstTag"; + var secondTagValue = "SecondTag"; + + double firstFieldValue = 23.2; + float secondFieldValue = 2323; + + ValidTestModel model = new ValidTestModel + { + Measurement = measurement, + Time = time, + Tag1 = firstTagValue, + Tag2 = secondTagValue, + PrimitiveField1 = firstFieldValue, + PrimitiveField2 = secondFieldValue + }; + + var expectedPoint = new Point + { + Name = measurement, + Timestamp = time, + Tags = new Dictionary + { + { "Tag1", firstTagValue }, // Tag name is set explicitly in attribute name argument + { "tag2", secondTagValue } + }, + Fields = new Dictionary + { + { "PrimitiveField1", firstFieldValue }, // Field name is set explicitly in attribute name argument + { "field2", secondFieldValue } + } + }; + + var actualPoint = model.ToPoint(); + + // Compare fields in point + Assert.Equal(expectedPoint.Fields.Count, actualPoint.Fields.Count); + Assert.Equal(expectedPoint.Fields.First().Key, actualPoint.Fields.First().Key); + Assert.Equal(expectedPoint.Fields.Last().Key, actualPoint.Fields.Last().Key); + Assert.Equal(expectedPoint.Fields.First().Value, actualPoint.Fields.First().Value); + Assert.Equal(expectedPoint.Fields.Last().Value, actualPoint.Fields.Last().Value); + + // Compare tags in point + Assert.Equal(expectedPoint.Tags.Count, actualPoint.Tags.Count); + Assert.Equal(expectedPoint.Tags.First().Key, actualPoint.Tags.First().Key); + Assert.Equal(expectedPoint.Tags.Last().Key, actualPoint.Tags.Last().Key); + Assert.Equal(expectedPoint.Tags.First().Value, actualPoint.Tags.First().Value); + Assert.Equal(expectedPoint.Tags.Last().Value, actualPoint.Tags.Last().Value); + + // Compare name and timestamp in point + Assert.Equal(expectedPoint.Name, actualPoint.Name); + Assert.Equal(expectedPoint.Timestamp, actualPoint.Timestamp); + } + + private class TestModelWithoutMeasurement + { + [Timestamp] + public DateTime Time { get; set; } + + [Tag("testtag")] + public string TestTag { get; set; } + + [Field("testfield")] + public string TestField { get; set; } + } + + private class TestModelWithoutFields + { + [Timestamp] + public DateTime Time { get; set; } + + [Measurement] + public string MyMeasurement { get; set; } + + [Tag] + public string TestTag { get; set; } + } + + private class TestModelWithMultipleMeasurements + { + [Measurement] + public string Measurement1 { get; set; } + + [Measurement] + public string Measurement2 { get; set; } + + [Timestamp] + public DateTime Time { get; set; } + + [Tag("testtag")] + public string TestTag { get; set; } + + [Field("testfield")] + public string TestField { get; set; } + } + + private class TestModelWithMultipleTimestamps + { + [Measurement] + public string Measurement { get; set; } + + [Timestamp] + public DateTime Time1 { get; set; } + + [Timestamp] + public DateTime Time2 { get; set; } + + [Tag("testtag")] + public string TestTag { get; set; } + + [Field("testfield")] + public string TestField { get; set; } + } + + private class TestModelWithNonStringTags + { + [Measurement] + public string Measurement { get; set; } + + [Timestamp] + public DateTime Time { get; set; } + + [Tag("primitivetag")] + public string PrimitiveTag { get; set; } + + [Tag("nonprimitivetag")] + public NonPrimitiveType NonPrimitiveTag { get; set; } + + [Field("primitivetag")] + public string PrimitiveField { get; set; } + } + + private class TestModelWithNonPrimitiveFields + { + [Measurement] + public string Measurement { get; set; } + + [Timestamp] + public DateTime Time { get; set; } + + [Tag("primitivetag")] + public string PrimitiveTag { get; set; } + + [Field("primitivefield")] + public string PrimitiveField { get; set; } + + [Field("nonprimitivefield")] + public NonPrimitiveType NonPrimitiveField { get; set; } + } + + private class ValidTestModel + { + [Measurement] + public string Measurement { get; set; } + + [Timestamp] + public DateTime Time { get; set; } + + [Tag] + public string Tag1 { get; set; } + + [Tag("tag2")] + public string Tag2 { get; set; } + + [Field] + public double PrimitiveField1 { get; set; } + + [Field("field2")] + public float PrimitiveField2 { get; set; } + } + + private class NonPrimitiveType + { + public string Whatever { get; set; } + public string Whatever2 { get; set; } + } + } +}