influxdb/pkg/csv2lp/csv_annotations.go

223 lines
7.0 KiB
Go

package csv2lp
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"time"
)
// annotationComment describes CSV annotation
type annotationComment struct {
// prefix in a CSV row that recognizes this annotation
prefix string
// flag is 0 to represent an annotation that is used for all data rows
// or a unique bit (>0) between supported annotation prefixes
flag uint8
// setupColumn setups metadata that drives the way of how column data
// are parsed, mandatory when flag > 0
setupColumn func(column *CsvTableColumn, columnValue string)
// setupTable setups metadata that drives the way of how the table data
// are parsed, mandatory when flag == 0
setupTable func(table *CsvTable, row []string) error
}
// isTableAnnotation returns true for a table-wide annotation, false for column-based annotations
func (a annotationComment) isTableAnnotation() bool {
return a.setupTable != nil
}
// matches tests whether an annotationComment can process the CSV comment row
func (a annotationComment) matches(comment string) bool {
return strings.HasPrefix(strings.ToLower(comment), a.prefix)
}
func createConstantOrConcatColumn(table *CsvTable, row []string, annotationName string) CsvTableColumn {
// adds a virtual column with constant value to all data rows
// supported types of constant annotation rows are:
// 1. "#constant,datatype,label,defaultValue"
// 2. "#constant,measurement,value"
// 3. "#constant,dateTime,value"
// 4. "#constant datatype,label,defaultValue"
// 5. "#constant measurement,value"
// 6. "#constant dateTime,value"
// defaultValue is optional, additional columns are ignored
col := CsvTableColumn{}
col.Index = -1 // this is a virtual column that never extracts data from data rows
// setup column data type
col.setupDataType(row[0])
var dataTypeIndex int
if len(col.DataType) == 0 && col.LinePart == 0 {
// type 1,2,3
dataTypeIndex = 1
if len(row) > 1 {
col.setupDataType(row[1])
}
} else {
// type 4,5,6
dataTypeIndex = 0
}
// setup label if available
if len(row) > dataTypeIndex+1 {
col.Label = row[dataTypeIndex+1]
}
// setup defaultValue if available
if len(row) > dataTypeIndex+2 {
col.DefaultValue = row[dataTypeIndex+2]
}
// support type 2,3,5,6 syntax for measurement and timestamp
if col.LinePart == linePartMeasurement || col.LinePart == linePartTime {
if col.DefaultValue == "" && col.Label != "" {
// type 2,3,5,6
col.DefaultValue = col.Label
col.Label = annotationName + " " + col.DataType
} else if col.Label == "" {
// setup a label if no label is supplied for focused error messages
col.Label = annotationName + " " + col.DataType
}
}
// add a virtual column to the table
return col
}
// constantSetupTable setups the supplied CSV table from #constant annotation
func constantSetupTable(table *CsvTable, row []string) error {
col := createConstantOrConcatColumn(table, row, "#constant")
// add a virtual column to the table
table.extraColumns = append(table.extraColumns, &col)
return nil
}
// computedReplacer is used to replace value in computed columns
var computedReplacer *regexp.Regexp = regexp.MustCompile(`\$\{[^}]+\}`)
// concatSetupTable setups the supplied CSV table from #concat annotation
func concatSetupTable(table *CsvTable, row []string) error {
col := createConstantOrConcatColumn(table, row, "#concat")
template := col.DefaultValue
col.ComputeValue = func(row []string) string {
return computedReplacer.ReplaceAllStringFunc(template, func(text string) string {
columnLabel := text[2 : len(text)-1] // ${columnLabel}
if placeholderColumn := table.Column(columnLabel); placeholderColumn != nil {
return placeholderColumn.Value(row)
}
log.Printf("WARNING: column %s: column '%s' cannot be replaced, no such column available", col.Label, columnLabel)
return ""
})
}
// add a virtual column to the table
table.extraColumns = append(table.extraColumns, &col)
// add validator to report error when no placeholder column is available
table.validators = append(table.validators, func(table *CsvTable) error {
placeholders := computedReplacer.FindAllString(template, len(template))
for _, placeholder := range placeholders {
columnLabel := placeholder[2 : len(placeholder)-1] // ${columnLabel}
if placeholderColumn := table.Column(columnLabel); placeholderColumn == nil {
return CsvColumnError{
Column: col.Label,
Err: fmt.Errorf("'%s' references an unknown column '%s', available columns are: %v",
template, columnLabel, strings.Join(table.ColumnLabels(), ",")),
}
}
}
return nil
})
return nil
}
// supportedAnnotations contains all supported CSV annotations comments
var supportedAnnotations = []annotationComment{
{
prefix: "#group",
flag: 1,
setupColumn: func(column *CsvTableColumn, value string) {
// standard flux query result annotation
if strings.HasSuffix(value, "true") {
// setup column's line part unless it is already set (#19452)
if column.LinePart == 0 {
column.LinePart = linePartTag
}
}
},
},
{
prefix: "#datatype",
flag: 2,
setupColumn: func(column *CsvTableColumn, value string) {
// standard flux query result annotation
column.setupDataType(value)
},
},
{
prefix: "#default",
flag: 4,
setupColumn: func(column *CsvTableColumn, value string) {
// standard flux query result annotation
column.DefaultValue = ignoreLeadingComment(value)
},
},
{
prefix: "#constant",
setupTable: constantSetupTable,
},
{
prefix: "#timezone",
setupTable: func(table *CsvTable, row []string) error {
// setup timezone for parsing timestamps, UTC by default
val := ignoreLeadingComment(row[0])
if val == "" && len(row) > 1 {
val = row[1] // #timezone,Local
}
tz, err := parseTimeZone(val)
if err != nil {
return fmt.Errorf("#timezone annotation: %v", err)
}
table.timeZone = tz
return nil
},
},
{
prefix: "#concat",
setupTable: concatSetupTable,
},
}
// ignoreLeadingComment returns a value without '#anyComment ' prefix
func ignoreLeadingComment(value string) string {
if len(value) > 0 && value[0] == '#' {
pos := strings.Index(value, " ")
if pos > 0 {
return strings.TrimLeft(value[pos+1:], " ")
}
return ""
}
return value
}
// parseTimeZone parses the supplied timezone from a string into a time.Location
//
// parseTimeZone("") // time.UTC
// parseTimeZone("local") // time.Local
// parseTimeZone("-0500") // time.FixedZone(-5*3600 + 0*60)
// parseTimeZone("+0200") // time.FixedZone(2*3600 + 0*60)
// parseTimeZone("EST") // time.LoadLocation("EST")
func parseTimeZone(val string) (*time.Location, error) {
switch {
case val == "":
return time.UTC, nil
case strings.ToLower(val) == "local":
return time.Local, nil
case val[0] == '-' || val[0] == '+':
if matched, _ := regexp.MatchString("[+-][0-9][0-9][0-9][0-9]", val); !matched {
return nil, fmt.Errorf("timezone '%s' is not +hhmm or -hhmm", val)
}
intVal, _ := strconv.Atoi(val)
offset := (intVal/100)*3600 + (intVal%100)*60
return time.FixedZone(val, offset), nil
default:
return time.LoadLocation(val)
}
}