Repository: influxdata/influxdb-client-go
Pull request #394 (2023-11-28 14:38:36)

feat: add DataToPoint utility to convert a custom struct to a write Point
## Proposed Changes
This PR adds a new `write.DataToPoint` function that converts custom structures to `write.Point`, the code uses the tags/annotations from [v3 client proposal](https://github.com/influxdata/influxdb-client-go/blob/91f06dfd4928dd6e95b123dc5b072abebc0df208/influxclient/writer.go#L152) to map structure fields to line protocol parts.

See also https://github.com/influxdata/influxdb-client-go/pull/389
## Checklist

<!-- Checkboxes below this note can be erased if not applicable to your Pull Request. -->

- [ ] CHANGELOG.md updated
- [x] Rebased/mergeable
- [x] A test has been added if appropriate
- [x] Tests pass
- [x] Commit messages are in [semantic format](https://seesparkbox.com/foundry/semantic_commit_messages)
- [x] Sign [CLA](https://www.influxdata.com/legal/cla/) (if not already signed)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e3d411..37bfa9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 ## 2.13.0 [unreleased]
 
+### Features
+
+- [#394](https://github.com/influxdata/influxdb-client-go/pull/394) Add `DataToPoint` utility to convert a struct to a `write.Point`
+
 ### Dependencies
 - [#393](https://github.com/influxdata/influxdb-client-go/pull/393) Replace deprecated `io/ioutil`
 - [#392](https://github.com/influxdata/influxdb-client-go/pull/392) Upgrade `deepmap/oapi-codegen` to new major version
diff --git a/api/data_to_point.go b/api/data_to_point.go
new file mode 100644
index 0000000..4977958
--- /dev/null
+++ b/api/data_to_point.go
@@ -0,0 +1,96 @@
+package api
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"time"
+
+	"github.com/influxdata/influxdb-client-go/v2/api/write"
+)
+
+// DataToPoint converts custom point structures into a Point.
+// Each visible field of the point on input must be annotated with
+// 'lp' prefix and values measurement,tag, field or timestamp.
+// Valid point must contain measurement and at least one field.
+//
+// A field with timestamp must be of a type time.Time
+//
+//	 type TemperatureSensor struct {
+//		  Measurement string `lp:"measurement"`
+//		  Sensor string `lp:"tag,sensor"`
+//		  ID string `lp:"tag,device_id"`
+//		  Temp float64 `lp:"field,temperature"`
+//		  Hum int	`lp:"field,humidity"`
+//		  Time time.Time `lp:"timestamp,temperature"`
+//		  Description string `lp:"-"`
+//	 }
+func DataToPoint(x interface{}) (*write.Point, error) {
+	t := reflect.TypeOf(x)
+	v := reflect.ValueOf(x)
+	if t.Kind() == reflect.Ptr {
+		t = t.Elem()
+		v = v.Elem()
+	}
+	if t.Kind() != reflect.Struct {
+		return nil, fmt.Errorf("cannot use %v as point", t)
+	}
+	fields := reflect.VisibleFields(t)
+
+	var measurement = ""
+	var lpTags = make(map[string]string)
+	var lpFields = make(map[string]interface{})
+	var lpTime time.Time
+
+	for _, f := range fields {
+		name := f.Name
+		if tag, ok := f.Tag.Lookup("lp"); ok {
+			if tag == "-" {
+				continue
+			}
+			parts := strings.Split(tag, ",")
+			if len(parts) > 2 {
+				return nil, fmt.Errorf("multiple tag attributes are not supported")
+			}
+			typ := parts[0]
+			if len(parts) == 2 {
+				name = parts[1]
+			}
+			t := getFieldType(v.FieldByIndex(f.Index))
+			if !validFieldType(t) {
+				return nil, fmt.Errorf("cannot use field '%s' of type '%v' as to create a point", f.Name, t)
+			}
+			switch typ {
+			case "measurement":
+				if measurement != "" {
+					return nil, fmt.Errorf("multiple measurement fields")
+				}
+				measurement = v.FieldByIndex(f.Index).String()
+			case "tag":
+				if name == "" {
+					return nil, fmt.Errorf("cannot use field '%s': invalid lp tag name \"\"", f.Name)
+				}
+				lpTags[name] = v.FieldByIndex(f.Index).String()
+			case "field":
+				if name == "" {
+					return nil, fmt.Errorf("cannot use field '%s': invalid lp field name \"\"", f.Name)
+				}
+				lpFields[name] = v.FieldByIndex(f.Index).Interface()
+			case "timestamp":
+				if f.Type != timeType {
+					return nil, fmt.Errorf("cannot use field '%s' as a timestamp", f.Name)
+				}
+				lpTime = v.FieldByIndex(f.Index).Interface().(time.Time)
+			default:
+				return nil, fmt.Errorf("invalid tag %s", typ)
+			}
+		}
+	}
+	if measurement == "" {
+		return nil, fmt.Errorf("no struct field with tag 'measurement'")
+	}
+	if len(lpFields) == 0 {
+		return nil, fmt.Errorf("no struct field with tag 'field'")
+	}
+	return write.NewPoint(measurement, lpTags, lpFields, lpTime), nil
+}
diff --git a/api/query.go b/api/query.go
index bce3e96..a547148 100644
--- a/api/query.go
+++ b/api/query.go
@@ -289,22 +289,6 @@ func checkParamsType(p interface{}) error {
 	return nil
 }
 
-// getFieldType extracts type of value
-func getFieldType(v reflect.Value) reflect.Type {
-	t := v.Type()
-	if t.Kind() == reflect.Ptr {
-		t = t.Elem()
-		v = v.Elem()
-	}
-	if t.Kind() == reflect.Interface && !v.IsNil() {
-		t = reflect.ValueOf(v.Interface()).Type()
-	}
-	return t
-}
-
-// timeType is the exact type for the Time
-var timeType = reflect.TypeOf(time.Time{})
-
 // validParamType validates that t is primitive type or string or interface
 func validParamType(t reflect.Type) bool {
 	return (t.Kind() > reflect.Invalid && t.Kind() < reflect.Complex64) ||
diff --git a/api/reflection.go b/api/reflection.go
new file mode 100644
index 0000000..8becc40
--- /dev/null
+++ b/api/reflection.go
@@ -0,0 +1,29 @@
+package api
+
+import (
+	"reflect"
+	"time"
+)
+
+// getFieldType extracts type of value
+func getFieldType(v reflect.Value) reflect.Type {
+	t := v.Type()
+	if t.Kind() == reflect.Ptr {
+		t = t.Elem()
+		v = v.Elem()
+	}
+	if t.Kind() == reflect.Interface && !v.IsNil() {
+		t = reflect.ValueOf(v.Interface()).Type()
+	}
+	return t
+}
+
+// timeType is the exact type for the Time
+var timeType = reflect.TypeOf(time.Time{})
+
+// validFieldType validates that t is primitive type or string or interface
+func validFieldType(t reflect.Type) bool {
+	return (t.Kind() > reflect.Invalid && t.Kind() < reflect.Complex64) ||
+		t.Kind() == reflect.String ||
+		t == timeType
+}


Test patch:
diff --git a/api/data_to_point_test.go b/api/data_to_point_test.go
new file mode 100644
index 0000000..d78317d
--- /dev/null
+++ b/api/data_to_point_test.go
@@ -0,0 +1,238 @@
+package api
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/influxdata/influxdb-client-go/v2/api/write"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	lp "github.com/influxdata/line-protocol"
+)
+
+func TestDataToPoint(t *testing.T) {
+	pointToLine := func(point *write.Point) string {
+		var buffer bytes.Buffer
+		e := lp.NewEncoder(&buffer)
+		e.SetFieldTypeSupport(lp.UintSupport)
+		e.FailOnFieldErr(true)
+		_, err := e.Encode(point)
+		if err != nil {
+			panic(err)
+		}
+		return buffer.String()
+	}
+	now := time.Now()
+	tests := []struct {
+		name  string
+		s     interface{}
+		line  string
+		error string
+	}{{
+		name: "test normal structure",
+		s: struct {
+			Measurement string    `lp:"measurement"`
+			Sensor      string    `lp:"tag,sensor"`
+			ID          string    `lp:"tag,device_id"`
+			Temp        float64   `lp:"field,temperature"`
+			Hum         int       `lp:"field,humidity"`
+			Time        time.Time `lp:"timestamp"`
+			Description string    `lp:"-"`
+		}{
+			"air",
+			"SHT31",
+			"10",
+			23.5,
+			55,
+			now,
+			"Room temp",
+		},
+		line: fmt.Sprintf("air,device_id=10,sensor=SHT31 humidity=55i,temperature=23.5 %d\n", now.UnixNano()),
+	},
+		{
+			name: "test pointer to normal structure",
+			s: &struct {
+				Measurement string    `lp:"measurement"`
+				Sensor      string    `lp:"tag,sensor"`
+				ID          string    `lp:"tag,device_id"`
+				Temp        float64   `lp:"field,temperature"`
+				Hum         int       `lp:"field,humidity"`
+				Time        time.Time `lp:"timestamp"`
+				Description string    `lp:"-"`
+			}{
+				"air",
+				"SHT31",
+				"10",
+				23.5,
+				55,
+				now,
+				"Room temp",
+			},
+			line: fmt.Sprintf("air,device_id=10,sensor=SHT31 humidity=55i,temperature=23.5 %d\n", now.UnixNano()),
+		}, {
+			name: "test no tag, no timestamp",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Temp        float64 `lp:"field,temperature"`
+			}{
+				"air",
+				23.5,
+			},
+			line: "air temperature=23.5\n",
+		},
+		{
+			name: "test default struct field name",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"tag"`
+				Temp        float64 `lp:"field"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+			},
+			line: "air,Sensor=SHT31 Temp=23.5\n",
+		},
+		{
+			name: "test missing struct field tag name",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"tag,"`
+				Temp        float64 `lp:"field"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+			},
+			error: `cannot use field 'Sensor': invalid lp tag name ""`,
+		},
+		{
+			name: "test missing struct field field name",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Temp        float64 `lp:"field,"`
+			}{
+				"air",
+				23.5,
+			},
+			error: `cannot use field 'Temp': invalid lp field name ""`,
+		},
+		{
+			name: "test missing measurement",
+			s: &struct {
+				Measurement string  `lp:"tag"`
+				Sensor      string  `lp:"tag"`
+				Temp        float64 `lp:"field"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+			},
+			error: `no struct field with tag 'measurement'`,
+		},
+		{
+			name: "test no field",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"tag"`
+				Temp        float64 `lp:"tag"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+			},
+			error: `no struct field with tag 'field'`,
+		},
+		{
+			name: "test double measurement",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"measurement"`
+				Temp        float64 `lp:"field,a"`
+				Hum         float64 `lp:"field,a"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+				43.1,
+			},
+			error: `multiple measurement fields`,
+		},
+		{
+			name: "test multiple tag attributes",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"tag,a,a"`
+				Temp        float64 `lp:"field,a"`
+				Hum         float64 `lp:"field,a"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+				43.1,
+			},
+			error: `multiple tag attributes are not supported`,
+		},
+		{
+			name: "test wrong timestamp type",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Sensor      string  `lp:"tag,sensor"`
+				Temp        float64 `lp:"field,a"`
+				Hum         float64 `lp:"timestamp"`
+			}{
+				"air",
+				"SHT31",
+				23.5,
+				43.1,
+			},
+			error: `cannot use field 'Hum' as a timestamp`,
+		},
+		{
+			name: "test map",
+			s: map[string]interface{}{
+				"measurement": "air",
+				"sensor":      "SHT31",
+				"temp":        23.5,
+			},
+			error: `cannot use map[string]interface {} as point`,
+		},
+		{
+			name: "test unsupported field type",
+			s: &struct {
+				Measurement string    `lp:"measurement"`
+				Temp        complex64 `lp:"field,a"`
+			}{
+				"air",
+				complex(1, 1),
+			},
+			error: `cannot use field 'Temp' of type 'complex64' as to create a point`,
+		},
+		{
+			name: "test unsupported lp tag value",
+			s: &struct {
+				Measurement string  `lp:"measurement"`
+				Temp        float64 `lp:"data,a"`
+			}{
+				"air",
+				1.0,
+			},
+			error: `invalid tag data`,
+		},
+	}
+	for _, ts := range tests {
+		t.Run(ts.name, func(t *testing.T) {
+			point, err := DataToPoint(ts.s)
+			if ts.error == "" {
+				require.NoError(t, err)
+				assert.Equal(t, ts.line, pointToLine(point))
+			} else {
+				require.Error(t, err)
+				assert.Equal(t, ts.error, err.Error())
+			}
+		})
+	}
+}