mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-05 16:15:56 +00:00
previously we allowed users to specify a granularity for timestamp
e.g. seconds, milli, micro, nano
however we converted everything to nano before we stored it.
This reduced the allowed range for all time units to what
was allowed by timestamp. For example, with second granularity
you can represent billions of years within the capacity of
int64 but with nano its somewhere b/w 100-200 years.
So now, for timeunits of seconds, milli, and micro the range
is year 0001 - 9999. These limits come from what Go
supports.
So this uses unit specific function to translate
timestamps to values and vice versa to increase
the time range.
In the process of increasing the range for timestamp and subsequent
testing, I found and addressed a few bugs:
- min/max queries were not using timestamp specific comparators so
added that.
- Values from Import/ingest come to FB as relative values to epoch
whereas other BSI fields come as actual values and then
becomes relative to their respective bases within FB. so some
specific handling of that was added.
- However! Set queries use timestamp strings which are, of course,
the actual value they designate. So they have to become
relative.
- When bitdepth is 0, Min/maxUnsigned functions did not run
resulting in a count of 0 when there
was an actual value that was 0.
Also, this removes (now) dead code and updates/adds tests.
1306 lines
33 KiB
Go
1306 lines
33 KiB
Go
package idk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
pilosacore "github.com/molecula/featurebase/v3"
|
|
pilosaclient "github.com/molecula/featurebase/v3/client"
|
|
"github.com/molecula/featurebase/v3/pql"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
// ErrSchemaChange is returned from Source.Record when the returned
|
|
// record has a different schema from the previous record.
|
|
ErrSchemaChange = errors.New("this record has a different schema from the previous record (or is the first one delivered). Please call Source.Schema() to fetch the schema in order to properly decode this record")
|
|
|
|
// ErrFlush is returned from Source.Record when the Source wants to
|
|
// signal that there may not be data for a while, so it's a good time
|
|
// to make sure all data which has been received is ingested. The
|
|
// record must be nil when ErrFlush is returned.
|
|
ErrFlush = errors.New("the Source is requesting the batch be flushed")
|
|
|
|
ErrFmtUnknownUnit = "unknown unit %q, please choose from d/h/m/s/ms/us/ns"
|
|
ErrIntOutOfRange = errors.New("value provided for int field is out of range")
|
|
ErrDecimalOutOfRange = errors.New("value provided for decimal field is out of range")
|
|
ErrTimestampOutOfRange = errors.New("value provided for timestamp field is out of range")
|
|
)
|
|
|
|
type (
|
|
// Source is an interface implemented by sources of data which can be
|
|
// ingested into Pilosa. Each Record returned from Record is described
|
|
// by the slice of Fields returned from Source.Schema directly after
|
|
// the call to Source.Record. If the error returned from Source.Record
|
|
// is nil, then the call to Schema which applied to the previous
|
|
// Record also applies to this Record. Source implementations are
|
|
// fundamentally not threadsafe (due to the interplay between Record
|
|
// and Schema).
|
|
Source interface {
|
|
// Record returns a data record, and an optional error. If the
|
|
// error is ErrSchemaChange, then the record is valid, but one
|
|
// should call Source.Schema to understand how each of its fields
|
|
// should be interpreted.
|
|
Record() (Record, error)
|
|
|
|
// Schema returns a slice of Fields which applies to the most
|
|
// recent Record returned from Source.Record. Every Field has a
|
|
// name and a type, and depending on the concrete type of the
|
|
// Field, may have other information which is relevant to how it
|
|
// should be indexed.
|
|
Schema() []Field
|
|
|
|
Close() error
|
|
}
|
|
|
|
Record interface {
|
|
// Commit notifies the Source which produced this record that it
|
|
// and any record which came before it have been completely
|
|
// processed. The Source can then take any necessary action to
|
|
// record which records have been processed, and restart from the
|
|
// earliest unprocessed record in the event of a failure.
|
|
Commit(ctx context.Context) error
|
|
|
|
Data() []interface{}
|
|
}
|
|
|
|
// OffsetStreamRecord is an extension of the record type which also tracks offsets within streams.
|
|
OffsetStreamRecord interface {
|
|
Record
|
|
|
|
// StreamOffset returns the stream from which the record originated, and the offset of the record within that stream.
|
|
StreamOffset() (key string, offset uint64)
|
|
}
|
|
|
|
Metadata interface {
|
|
// SchemaMetadata returns a string representation of source-specific details
|
|
// about the schema.
|
|
SchemaMetadata() string
|
|
SchemaSubject() string
|
|
SchemaSchema() string
|
|
SchemaVersion() int
|
|
SchemaID() int
|
|
}
|
|
|
|
// Field knows how to interpret values of different types and tells
|
|
// how they get indexed in Pilosa. Every field implementation should
|
|
// be a struct named like <something>Field, and have as members
|
|
// `NameVal string` and `DestNameVal string`, where NameVal contains
|
|
// the name of the field at the source, and DestNameVal contains the
|
|
// name of the field at the destination (pilosa)
|
|
//
|
|
// Many Field implementations have a Quantum field which can be any
|
|
// valid Pilosa time quantum, e.g. "Y", "YMDH", "DH", etc. If Quantum
|
|
// is set to a valid quantum, the Pilosa field created for this field
|
|
// will be of type "time". Other fields which control field type will
|
|
// be ignored until/if Pilosa supports time+(othertype) fields.
|
|
Field interface {
|
|
Name() string
|
|
DestName() string
|
|
PilosafyVal(val interface{}) (interface{}, error) // TODO rename this
|
|
}
|
|
)
|
|
|
|
// FieldsEqual is used in testing to compare Fields. The pointers make it a bit tricky for IntField.
|
|
func FieldsEqual(f1, f2 Field) bool {
|
|
if reflect.TypeOf(f1) != reflect.TypeOf(f2) {
|
|
return false
|
|
}
|
|
switch f1t := f1.(type) {
|
|
case IgnoreField, IDField, BoolField, RecordTimeField, StringField, LookupTextField, DecimalField, SignedIntBoolKeyField, StringArrayField, IDArrayField, TimestampField, DateIntField:
|
|
return f1 == f2
|
|
case IntField:
|
|
f2t := f2.(IntField)
|
|
if f1t.NameVal == f2t.NameVal && f1t.DestNameVal == f2t.DestNameVal && f1t.ForeignIndex == f2t.ForeignIndex {
|
|
if !(f1t.Min == nil && f2t.Min == nil) {
|
|
if f1t.Min == nil || f2t.Min == nil {
|
|
return false
|
|
}
|
|
if *f1t.Min != *f2t.Min {
|
|
return false
|
|
}
|
|
}
|
|
if !(f1t.Max == nil && f2t.Max == nil) {
|
|
if f1t.Max == nil || f2t.Max == nil {
|
|
return false
|
|
}
|
|
if *f1t.Max != *f2t.Max {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
default:
|
|
panic(fmt.Sprintf("unknown field type %T when comparing fields", f1))
|
|
}
|
|
}
|
|
|
|
// CacheConfigOf returns CacheConfig of the Field.
|
|
func CacheConfigOf(f Field) CacheConfig {
|
|
switch f := f.(type) {
|
|
case StringField:
|
|
if f.CacheConfig == nil {
|
|
if f.Quantum != "" {
|
|
return noneCacheConfig
|
|
}
|
|
return defaultCacheConfig
|
|
}
|
|
return *f.CacheConfig
|
|
case StringArrayField:
|
|
if f.CacheConfig == nil {
|
|
if f.Quantum != "" {
|
|
return noneCacheConfig
|
|
}
|
|
return defaultCacheConfig
|
|
}
|
|
return *f.CacheConfig
|
|
case IDField:
|
|
if f.CacheConfig == nil {
|
|
if f.Quantum != "" {
|
|
return noneCacheConfig
|
|
}
|
|
return defaultCacheConfig
|
|
}
|
|
return *f.CacheConfig
|
|
case IDArrayField:
|
|
if f.CacheConfig == nil {
|
|
if f.Quantum != "" {
|
|
return noneCacheConfig
|
|
}
|
|
return defaultCacheConfig
|
|
}
|
|
return *f.CacheConfig
|
|
default:
|
|
return defaultCacheConfig
|
|
}
|
|
}
|
|
|
|
// QuantumOf returns Quantum of the Field.
|
|
func QuantumOf(fld Field) string {
|
|
switch ft := fld.(type) {
|
|
case IDField:
|
|
return ft.Quantum
|
|
case StringField:
|
|
return ft.Quantum
|
|
case StringArrayField:
|
|
return ft.Quantum
|
|
case IDArrayField:
|
|
return ft.Quantum
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func TTLOf(fld Field) (time.Duration, error) {
|
|
var ttl time.Duration
|
|
var err error
|
|
|
|
switch ft := fld.(type) {
|
|
case IDField:
|
|
if ft.TTL != "" {
|
|
ttl, err = time.ParseDuration(ft.TTL)
|
|
} else {
|
|
return 0, nil
|
|
}
|
|
case StringField:
|
|
if ft.TTL != "" {
|
|
ttl, err = time.ParseDuration(ft.TTL)
|
|
} else {
|
|
return 0, nil
|
|
}
|
|
case StringArrayField:
|
|
if ft.TTL != "" {
|
|
ttl, err = time.ParseDuration(ft.TTL)
|
|
} else {
|
|
return 0, nil
|
|
}
|
|
case IDArrayField:
|
|
if ft.TTL != "" {
|
|
ttl, err = time.ParseDuration(ft.TTL)
|
|
} else {
|
|
return 0, nil
|
|
}
|
|
default:
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return ttl, errors.Wrapf(err, "unable to parse TTL from field %s", fld.Name())
|
|
} else {
|
|
return ttl, nil
|
|
}
|
|
}
|
|
|
|
// HasMutex returns Mutex value of StringField or IDField, otherwise false
|
|
func HasMutex(fld Field) bool {
|
|
if sfld, ok := fld.(StringField); ok && sfld.Quantum == "" {
|
|
return sfld.Mutex
|
|
}
|
|
if sfld, ok := fld.(IDField); ok && sfld.Quantum == "" {
|
|
return sfld.Mutex
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IgnoreField can be used when you wish not to process one of the
|
|
// input fields, but it is inconvenient to remove it ahead of time.
|
|
type IgnoreField struct{}
|
|
|
|
func (IgnoreField) Name() string { return "" }
|
|
func (IgnoreField) DestName() string { return "" }
|
|
func (IgnoreField) PilosafyVal(interface{}) (interface{}, error) { return nil, nil }
|
|
|
|
type IDField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
|
|
// Mutex denotes whether we need to enforce that each record only
|
|
// has a single value for this field. Put another way, says
|
|
// whether a new value for this field be treated as adding an
|
|
// additional value, or replacing the existing value (if there is
|
|
// one).
|
|
Mutex bool
|
|
|
|
// Quantum — see note about Quantum on "Field" interface.
|
|
Quantum string
|
|
|
|
TTL string
|
|
|
|
*CacheConfig
|
|
}
|
|
|
|
func (id IDField) Name() string { return id.NameVal }
|
|
func (id IDField) DestName() string {
|
|
if id.DestNameVal == "" {
|
|
return id.NameVal
|
|
}
|
|
|
|
return id.DestNameVal
|
|
}
|
|
func (id IDField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
} else if vs, ok := val.(string); ok && vs == "" {
|
|
return nil, nil
|
|
}
|
|
return toUint64(val)
|
|
}
|
|
|
|
type BoolField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
}
|
|
|
|
func (b BoolField) Name() string { return b.NameVal }
|
|
func (b BoolField) DestName() string {
|
|
if b.DestNameVal == "" {
|
|
return b.NameVal
|
|
}
|
|
|
|
return b.DestNameVal
|
|
}
|
|
func (b BoolField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
} else if vs, ok := val.(string); ok && vs == "" {
|
|
return nil, nil
|
|
}
|
|
return toBool(val)
|
|
}
|
|
|
|
var (
|
|
MinTimestampNano = time.Unix(-1<<32, 0).UTC() // 1833-11-24T17:31:44Z
|
|
MaxTimestampNano = time.Unix(1<<32, 0).UTC() // 2106-02-07T06:28:16Z
|
|
MinTimestamp = time.Unix(-62135596799, 0).UTC() // 0001-01-01T00:00:01Z
|
|
MaxTimestamp = time.Unix(253402300799, 0).UTC() // 9999-12-31T23:59:59Z
|
|
)
|
|
|
|
const (
|
|
Custom = Unit("c")
|
|
Day = Unit("d")
|
|
Hour = Unit("h")
|
|
Minute = Unit("m")
|
|
Second = Unit("s")
|
|
Millisecond = Unit("ms")
|
|
Microsecond = Unit("us")
|
|
Nanosecond = Unit("ns")
|
|
|
|
DefaultUnit = Second
|
|
)
|
|
|
|
type Unit string
|
|
|
|
func (u Unit) unit() Unit {
|
|
s := strings.ToLower(string(u))
|
|
if s == "" {
|
|
return DefaultUnit
|
|
}
|
|
return Unit(s)
|
|
}
|
|
|
|
func (u Unit) IsCustom() bool {
|
|
return u.unit() == Custom
|
|
}
|
|
|
|
func (u Unit) Duration() (time.Duration, error) {
|
|
duration := time.Duration(1)
|
|
switch u.unit() {
|
|
case Day:
|
|
duration *= 24
|
|
fallthrough
|
|
case Hour:
|
|
duration *= 60
|
|
fallthrough
|
|
case Minute:
|
|
duration *= 60
|
|
fallthrough
|
|
case Second:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Millisecond:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Microsecond:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Nanosecond:
|
|
return duration, nil
|
|
}
|
|
return 0, errors.Errorf(ErrFmtUnknownUnit, u)
|
|
}
|
|
|
|
// ToNanos returns the number of Nanoseconds per given Unit
|
|
func (u Unit) ToNanos() (int64, error) {
|
|
duration := int64(1)
|
|
switch u.unit() {
|
|
case Day:
|
|
duration *= 24
|
|
fallthrough
|
|
case Hour:
|
|
duration *= 60
|
|
fallthrough
|
|
case Minute:
|
|
duration *= 60
|
|
fallthrough
|
|
case Second:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Millisecond:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Microsecond:
|
|
duration *= 1000
|
|
fallthrough
|
|
case Nanosecond:
|
|
return duration, nil
|
|
}
|
|
return 0, errors.Errorf(ErrFmtUnknownUnit, u)
|
|
}
|
|
|
|
func (u Unit) DurationFromValue(val int64) (time.Duration, error) {
|
|
scale, err := u.Duration()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if max, min := int64(math.MaxInt64/scale), int64(math.MinInt64/scale); val > max || val < min {
|
|
return 0, errors.Errorf("%d%s is outside representable time scales, must be between %d and %d", val, u, min, max)
|
|
}
|
|
return time.Duration(val) * scale, nil
|
|
}
|
|
|
|
func (r RecordTimeField) epoch() time.Time {
|
|
if r.Epoch.IsZero() {
|
|
return time.Unix(0, 0)
|
|
}
|
|
return r.Epoch
|
|
}
|
|
|
|
// RecordTimeField applies to whole record, but doesn't have a name
|
|
// (or quantum) of its own since it applies to any other time fields
|
|
// in the record.
|
|
type RecordTimeField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
Layout string // Layout tells how the time should be parsed. Defaults to RFC3339.
|
|
// need a way to create other time fields in the record (add time/quantum to String, StringArray, ID, IDArray?)
|
|
// do we need a way to have timefields in a record with independent times/values
|
|
Epoch time.Time
|
|
Unit Unit
|
|
}
|
|
|
|
func (r RecordTimeField) Name() string { return r.NameVal }
|
|
func (r RecordTimeField) DestName() string {
|
|
if r.DestNameVal == "" {
|
|
return r.NameVal
|
|
}
|
|
|
|
return r.DestNameVal
|
|
}
|
|
|
|
// PilosafyVal for RecordTimeField always returns a time.Time or nil.
|
|
func (r RecordTimeField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if valt, ok := val.(time.Time); ok {
|
|
return valt, nil
|
|
}
|
|
if !r.Epoch.IsZero() || r.Unit != "" {
|
|
result, err := timeFromEpoch(val, r.epoch(), r.Unit)
|
|
if err != nil {
|
|
err = errors.Wrap(err, "converting RecordTimeField from epoch")
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
result, err := timeFromTimestring(val, r.layout())
|
|
if err != nil {
|
|
err = errors.Wrap(err, "converting RecordTimeField from layout")
|
|
}
|
|
if result.IsZero() {
|
|
return nil, err
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (r RecordTimeField) layout() string {
|
|
if r.Layout == "" {
|
|
return time.RFC3339
|
|
}
|
|
return r.Layout
|
|
}
|
|
|
|
// CacheConfig - type (ranked, lru, none) and size.
|
|
type CacheConfig struct {
|
|
CacheType pilosaclient.CacheType
|
|
CacheSize int
|
|
}
|
|
|
|
var defaultCacheConfig = CacheConfig{CacheType: pilosaclient.CacheTypeRanked, CacheSize: pilosacore.DefaultCacheSize}
|
|
var noneCacheConfig = CacheConfig{CacheType: "", CacheSize: 0}
|
|
|
|
func (cfg CacheConfig) setOption() pilosaclient.FieldOption {
|
|
if cfg == (CacheConfig{}) {
|
|
cfg = defaultCacheConfig
|
|
}
|
|
|
|
return pilosaclient.OptFieldTypeSet(pilosaclient.CacheType(cfg.CacheType), cfg.CacheSize)
|
|
}
|
|
|
|
func (cfg CacheConfig) mutexOption() pilosaclient.FieldOption {
|
|
if cfg == (CacheConfig{}) {
|
|
cfg = defaultCacheConfig
|
|
}
|
|
|
|
return pilosaclient.OptFieldTypeMutex(pilosaclient.CacheType(cfg.CacheType), cfg.CacheSize)
|
|
}
|
|
|
|
type StringField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
|
|
// Mutex denotes whether we need to enforce that each record only
|
|
// has a single value for this field. Put another way, says
|
|
// whether a new value for this field be treated as adding an
|
|
// additional value, or replacing the existing value (if there is
|
|
// one).
|
|
Mutex bool
|
|
|
|
// Quantum — see note about Quantum on "Field" interface.
|
|
Quantum string
|
|
|
|
TTL string
|
|
|
|
*CacheConfig
|
|
}
|
|
|
|
func (s StringField) Name() string { return s.NameVal }
|
|
func (s StringField) DestName() string {
|
|
if s.DestNameVal == "" {
|
|
return s.NameVal
|
|
}
|
|
|
|
return s.DestNameVal
|
|
}
|
|
func (s StringField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
return toString(val)
|
|
}
|
|
|
|
type LookupTextField struct {
|
|
// NOTE this implements the Field interface for simplicity of implementation/API, but that interface is intended for data going into pilosa, while this is not.
|
|
NameVal string
|
|
DestNameVal string
|
|
// TODO this might should reference the lookupDB
|
|
}
|
|
|
|
func (s LookupTextField) Name() string { return s.NameVal }
|
|
func (s LookupTextField) DestName() string {
|
|
if s.DestNameVal == "" {
|
|
return s.NameVal
|
|
}
|
|
|
|
return s.DestNameVal
|
|
}
|
|
func (s LookupTextField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
return toString(val)
|
|
}
|
|
|
|
// IntField - if you add any new fields to this struct, please update the FieldsEqual function to accomodate.
|
|
type IntField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
Min *int64
|
|
Max *int64
|
|
ForeignIndex string
|
|
}
|
|
|
|
func (i IntField) Name() string { return i.NameVal }
|
|
func (i IntField) DestName() string {
|
|
if i.DestNameVal == "" {
|
|
return i.NameVal
|
|
}
|
|
|
|
return i.DestNameVal
|
|
}
|
|
func (i IntField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
if valS, ok := val.(string); ok && i.ForeignIndex != "" {
|
|
return valS, nil
|
|
} else if ok && valS == "" {
|
|
return nil, nil
|
|
}
|
|
asInt, err := toInt64(val)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if i.Min != nil && asInt < *i.Min {
|
|
return nil, errors.Wrapf(ErrIntOutOfRange, "field = %v, value %v is smaller than min allowed %v", i.Name(), asInt, *i.Min)
|
|
}
|
|
if i.Max != nil && asInt > *i.Max {
|
|
return nil, errors.Wrapf(ErrIntOutOfRange, "field = %v, value %v is bigger than max allowed %v", i.Name(), asInt, *i.Max)
|
|
}
|
|
return asInt, nil
|
|
}
|
|
|
|
const DecimalPrecision = 18
|
|
|
|
type DecimalField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
Scale int64
|
|
}
|
|
|
|
func (d DecimalField) Name() string { return d.NameVal }
|
|
func (d DecimalField) DestName() string {
|
|
if d.DestNameVal == "" {
|
|
return d.NameVal
|
|
}
|
|
|
|
return d.DestNameVal
|
|
}
|
|
|
|
// PilosafyVal for DecimalField always returns an int64. If the
|
|
// incoming value is anything but a float or string we attempt to
|
|
// convert to int64 and then scale it. Strings are attempted to be
|
|
// parsed into floats, and all values are scaled by the 10^scale
|
|
// before being returned. Byte slices are assumed to represent the
|
|
// already scaled value and are interpreted as int64.
|
|
func (d DecimalField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
// Make sure the provided scale is supported before proceeding.
|
|
// Note: the "1" value below doesn't matter; we're just
|
|
// validating the scale.
|
|
// Also, the existing tests don't exercise this check because
|
|
// they error creating the field prior to getting here.
|
|
// TODO: let's move scale validation to the pql package so
|
|
// that it can control the specific error message.
|
|
if !pql.NewDecimal(1, d.Scale).IsValid() {
|
|
return nil, errors.Errorf("scale values outside the range [0,19] are not supported: %d", d.Scale)
|
|
}
|
|
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
if vs, ok := val.(string); ok {
|
|
if vs == "" {
|
|
return nil, nil
|
|
}
|
|
asInt, err := scaledStringToInt(d.Scale, vs)
|
|
if err != nil {
|
|
// scaledStringToInt returns 0 on error. If there is a problem parsing the string,
|
|
// we want to import nil.
|
|
return nil, errors.Wrap(err, ErrDecimalOutOfRange.Error())
|
|
}
|
|
return asInt, nil
|
|
}
|
|
switch vt := val.(type) {
|
|
case pql.Decimal:
|
|
return vt.ToInt64(d.Scale), nil
|
|
|
|
case float32:
|
|
v := vt * float32(math.Pow10(int(d.Scale)))
|
|
return int64(v), nil
|
|
case float64:
|
|
vt = vt * math.Pow10(int(d.Scale))
|
|
return int64(vt), nil
|
|
case []byte:
|
|
// 16: int64(value)+int64(scale)
|
|
// 8: int64(value)
|
|
var tmp [8]byte
|
|
if len(vt) == 16 {
|
|
value := int64(binary.BigEndian.Uint64(vt[0:8]))
|
|
scale := int64(binary.BigEndian.Uint64(vt[8:16]))
|
|
return pql.NewDecimal(value, scale).ToInt64(d.Scale), nil
|
|
} else if len(vt) == 8 {
|
|
return int64(binary.BigEndian.Uint64(vt)), nil
|
|
} else if len(vt) < 8 {
|
|
copy(tmp[8-len(vt):], vt)
|
|
return int64(binary.BigEndian.Uint64(tmp[:])), nil
|
|
} else {
|
|
return nil, errors.Errorf("can only support decimal value up to 8 bytes, or 16 bytes containing value and scale, got %d for %s", len(vt), d.Name())
|
|
}
|
|
default:
|
|
v, err := toInt64(val)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "couldn't convert %v to int64 for decimal field", val)
|
|
}
|
|
return pql.NewDecimal(v, 0).ToInt64(d.Scale), nil
|
|
}
|
|
}
|
|
|
|
// SignedIntBoolKeyField translates a signed integer value to a (rowID, bool)
|
|
// pair corresponding to the magnitude and sign of the original value. This
|
|
// may be used to specify whether a bool value is to be set (positive/true)
|
|
// or cleared (negative/false).
|
|
type SignedIntBoolKeyField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
}
|
|
|
|
func (b SignedIntBoolKeyField) Name() string { return b.NameVal }
|
|
func (b SignedIntBoolKeyField) DestName() string {
|
|
if b.DestNameVal == "" {
|
|
return b.NameVal
|
|
}
|
|
|
|
return b.DestNameVal
|
|
}
|
|
|
|
func (SignedIntBoolKeyField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
} else if vs, ok := val.(string); ok && vs == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return toInt64(val)
|
|
}
|
|
|
|
type StringArrayField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
|
|
// Quantum — see note about Quantum on "Field" interface.
|
|
Quantum string
|
|
|
|
TTL string
|
|
|
|
*CacheConfig
|
|
}
|
|
|
|
func (s StringArrayField) Name() string { return s.NameVal }
|
|
func (s StringArrayField) DestName() string {
|
|
if s.DestNameVal == "" {
|
|
return s.NameVal
|
|
}
|
|
|
|
return s.DestNameVal
|
|
}
|
|
func (StringArrayField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
return toStringArray(val)
|
|
}
|
|
|
|
type IDArrayField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
|
|
// Quantum — see note about Quantum on "Field" interface.
|
|
Quantum string
|
|
|
|
TTL string
|
|
|
|
*CacheConfig
|
|
}
|
|
|
|
func (i IDArrayField) Name() string { return i.NameVal }
|
|
func (i IDArrayField) DestName() string {
|
|
if i.DestNameVal == "" {
|
|
return i.NameVal
|
|
}
|
|
|
|
return i.DestNameVal
|
|
}
|
|
func (IDArrayField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
return toUint64Array(val)
|
|
}
|
|
|
|
type TimestampField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
Layout string
|
|
Epoch time.Time
|
|
Unit Unit
|
|
Granularity string
|
|
}
|
|
|
|
func (d TimestampField) Name() string { return d.NameVal }
|
|
|
|
func (d TimestampField) DestName() string {
|
|
if d.DestNameVal == "" {
|
|
return d.NameVal
|
|
}
|
|
return d.DestNameVal
|
|
}
|
|
|
|
// ValToTimestamp takes a timeunit and an integer value and converts it to time.Time
|
|
func ValToTimestamp(unit string, val int64) (time.Time, error) {
|
|
switch unit {
|
|
case string(Second):
|
|
return time.Unix(val, 0).UTC(), nil
|
|
case string(Millisecond):
|
|
return time.UnixMilli(val).UTC(), nil
|
|
case string(Microsecond):
|
|
return time.UnixMicro(val).UTC(), nil
|
|
case string(Nanosecond):
|
|
return time.Unix(0, val).UTC(), nil
|
|
default:
|
|
return time.Time{}, errors.Errorf("Unknown time unit: '%v'", unit)
|
|
}
|
|
}
|
|
|
|
// TimestampToVal takes a time unit and a time.Time and converts it to an integer value
|
|
func TimestampToVal(unit Unit, ts time.Time) int64 {
|
|
switch unit {
|
|
case Second:
|
|
return ts.Unix()
|
|
case Millisecond:
|
|
return ts.UnixMilli()
|
|
case Microsecond:
|
|
return ts.UnixMicro()
|
|
case Nanosecond:
|
|
return ts.UnixNano()
|
|
}
|
|
return 0
|
|
|
|
}
|
|
|
|
// PilosafyVal for TimestampField always returns an int or nil.
|
|
func (t TimestampField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
if val == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var dur int64
|
|
// Check if the epoch alone is out-of-range. If so, ingest should halt, regardless
|
|
// of state of the timestamp out-of-range CLI option.
|
|
if err := validateTimestamp(t.granularity(), t.epoch()); err != nil {
|
|
return nil, errors.Wrap(err, "validating epoch")
|
|
}
|
|
|
|
epochAsVal := TimestampToVal(t.granularity(), t.epoch())
|
|
if _, ok := val.(time.Time); ok || (t.Epoch.IsZero() && t.Unit == "") {
|
|
ts, err := timeFromTimestring(val, t.layout())
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "out of range") {
|
|
return nil, errors.Wrap(err, ErrTimestampOutOfRange.Error())
|
|
}
|
|
return nil, errors.Wrap(err, "converting TimestampField from layout")
|
|
}
|
|
if err := validateTimestamp(t.granularity(), ts); err != nil {
|
|
return nil, errors.Wrap(ErrTimestampOutOfRange, "validating timestamp")
|
|
}
|
|
|
|
tsAsVal := TimestampToVal(t.granularity(), ts)
|
|
|
|
dur = tsAsVal - epochAsVal
|
|
} else {
|
|
valAsInt, err := toInt64(val)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "out of range") {
|
|
return nil, errors.Wrap(err, ErrTimestampOutOfRange.Error())
|
|
}
|
|
return nil, errors.Wrap(err, "converting value to int64")
|
|
}
|
|
|
|
// Conversion ratio to scale incoming Units to Granularity
|
|
granNanos, err := Unit(t.granularity()).ToNanos()
|
|
if err != nil || granNanos == 0 {
|
|
return nil, errors.Wrap(err, "granularity not supported")
|
|
}
|
|
unitNanos, err := Unit(t.Unit).ToNanos()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "unit not supported")
|
|
}
|
|
scale := float64(unitNanos) / float64(granNanos)
|
|
|
|
dur = int64(float64(valAsInt) * scale)
|
|
if (dur >= 0 && valAsInt < 0) || (dur < 0 && valAsInt > 0) {
|
|
return nil, errors.Wrap(ErrTimestampOutOfRange, "timestamp value out of range at specified granularity")
|
|
}
|
|
|
|
if err := validateDuration(dur, epochAsVal, Unit(t.granularity())); err != nil {
|
|
return nil, errors.Wrap(err, "validating duration")
|
|
}
|
|
}
|
|
|
|
return dur, nil
|
|
}
|
|
|
|
// validateTimestamp checks if the timestamp is within the range of what FB accepts.
|
|
func validateTimestamp(unit Unit, ts time.Time) error {
|
|
// Min and Max timestamps that Featurebase accepts
|
|
var minStamp, maxStamp time.Time
|
|
switch unit {
|
|
case Nanosecond:
|
|
minStamp = MinTimestampNano
|
|
maxStamp = MaxTimestampNano
|
|
default:
|
|
minStamp = MinTimestamp
|
|
maxStamp = MaxTimestamp
|
|
}
|
|
|
|
if ts.Before(minStamp) || ts.After(maxStamp) {
|
|
return errors.New(fmt.Sprintf("timestamp value must be within min: %v and max: %v", minStamp, maxStamp))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateDuration checks if the duration will overflow. Users can provide a custom epoch but
|
|
// Featurebase will ultimately convert this to some duration relative to the Unix epoch.
|
|
// So if the custom epoch + the provided value in the desired units is too far from
|
|
// Unix epoch such that it causes an interger overflow, this will return an error.
|
|
func validateDuration(dur int64, offset int64, granularity Unit) error {
|
|
var minInt, maxInt int64
|
|
switch granularity {
|
|
case Second:
|
|
minInt = MinTimestamp.Unix()
|
|
maxInt = MaxTimestamp.Unix()
|
|
case Millisecond:
|
|
minInt = MinTimestamp.UnixMilli()
|
|
maxInt = MaxTimestamp.UnixMilli()
|
|
case Microsecond:
|
|
minInt = MinTimestamp.UnixMicro()
|
|
maxInt = MaxTimestamp.UnixMicro()
|
|
case Nanosecond:
|
|
minInt = MinTimestampNano.UnixNano()
|
|
maxInt = MaxTimestampNano.UnixNano()
|
|
}
|
|
|
|
if offset > 0 {
|
|
if dur > maxInt-offset {
|
|
return errors.Wrap(ErrTimestampOutOfRange, "value + epoch is too far from Unix epoch")
|
|
}
|
|
} else if dur < minInt-offset {
|
|
return errors.Wrap(ErrTimestampOutOfRange, "value + epoch is too far from Unix epoch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Return default granularity if not set
|
|
func (t TimestampField) granularity() Unit {
|
|
if t.Granularity == "" {
|
|
return "s"
|
|
}
|
|
return Unit(t.Granularity)
|
|
}
|
|
|
|
// Return default layout if not set
|
|
func (t TimestampField) layout() string {
|
|
if t.Layout == "" {
|
|
return time.RFC3339Nano
|
|
}
|
|
return t.Layout
|
|
}
|
|
|
|
// Return default epoch if not set
|
|
func (t TimestampField) epoch() time.Time {
|
|
if t.Epoch.IsZero() {
|
|
return time.Unix(0, 0)
|
|
}
|
|
return t.Epoch
|
|
}
|
|
|
|
type DateIntField struct {
|
|
NameVal string
|
|
DestNameVal string
|
|
Layout string
|
|
Epoch time.Time
|
|
Unit Unit
|
|
CustomUnit string
|
|
}
|
|
|
|
func (d DateIntField) Name() string { return d.NameVal }
|
|
func (d DateIntField) DestName() string {
|
|
if d.DestNameVal == "" {
|
|
return d.NameVal
|
|
}
|
|
|
|
return d.DestNameVal
|
|
}
|
|
|
|
// PilosafyVal for a DateIntField takes a time.Time and int64 which
|
|
// represents the number units from the epoch.
|
|
func (d DateIntField) PilosafyVal(val interface{}) (interface{}, error) {
|
|
var vt time.Time
|
|
var err error
|
|
switch valt := val.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case []byte:
|
|
if len(valt) == 0 {
|
|
return nil, nil
|
|
}
|
|
vt, err = parseTimeWithLayout(d.layout(), string(valt))
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "converting DateIntField []byte")
|
|
}
|
|
case string:
|
|
vt, err = parseTimeWithLayout(d.layout(), valt)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "converting DateIntField string")
|
|
}
|
|
case time.Time:
|
|
vt = valt
|
|
case uint, int, uint8, uint16, uint32, uint64, int8, int16, int32, int64:
|
|
return toInt64(valt)
|
|
default:
|
|
return nil, errors.Errorf("didn't know how to handle %v of type %[1]T in DateIntField", valt)
|
|
}
|
|
|
|
dur := vt.Sub(d.epoch())
|
|
|
|
var unit time.Duration
|
|
if d.Unit.IsCustom() {
|
|
if unit, err = time.ParseDuration(d.CustomUnit); err != nil {
|
|
return nil, errors.Wrapf(err, "parsing custom unit %s", d.CustomUnit)
|
|
}
|
|
} else {
|
|
if unit, err = d.Unit.Duration(); err != nil {
|
|
return nil, errors.Wrapf(err, "parsing unit %s", d.Unit)
|
|
}
|
|
}
|
|
|
|
return int64(dur / unit), nil
|
|
}
|
|
|
|
func (d DateIntField) epoch() time.Time {
|
|
if d.Epoch.IsZero() {
|
|
return time.Unix(0, 0)
|
|
}
|
|
return d.Epoch
|
|
}
|
|
|
|
func (d DateIntField) layout() string {
|
|
if d.Layout == "" {
|
|
// this is kind of a ridiculous default for layout
|
|
return "2006-01-02"
|
|
}
|
|
return d.Layout
|
|
}
|
|
|
|
func parseTimeWithLayout(layout string, val string) (time.Time, error) {
|
|
if val == "0000-00-00" { // TODO this is kind of a special hack
|
|
// that we should remove - was added
|
|
// for a particular case of unparsable
|
|
// data that we wanted to ignore.
|
|
return time.Time{}, nil
|
|
}
|
|
tim, err := time.Parse(layout, val)
|
|
if err != nil {
|
|
return time.Time{}, errors.Wrapf(err, "parsing time string %s", val)
|
|
}
|
|
return tim, nil
|
|
}
|
|
|
|
func timeFromEpoch(val interface{}, epoch time.Time, unit Unit) (interface{}, error) {
|
|
valAsInt, err := toInt64(val)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "converting value to int64")
|
|
}
|
|
|
|
dur, err := unit.DurationFromValue(valAsInt)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getting duration from value")
|
|
}
|
|
|
|
return epoch.Add(dur), err
|
|
}
|
|
|
|
func timeFromTimestring(val interface{}, layout string) (time.Time, error) {
|
|
if val == nil {
|
|
return time.Time{}, nil
|
|
}
|
|
switch valt := val.(type) {
|
|
case nil:
|
|
return time.Time{}, nil
|
|
case []byte:
|
|
if len(valt) == 0 {
|
|
return time.Time{}, nil
|
|
}
|
|
vt, err := parseTimeWithLayout(layout, string(valt))
|
|
if err != nil {
|
|
return time.Time{}, errors.Wrap(err, "parsing []byte")
|
|
}
|
|
return vt, nil
|
|
case string:
|
|
if valt == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
vt, err := parseTimeWithLayout(layout, valt)
|
|
if err != nil {
|
|
return time.Time{}, errors.Wrapf(err, "parsing time string %s", valt)
|
|
}
|
|
return vt, nil
|
|
case time.Time:
|
|
return valt, nil
|
|
default:
|
|
return time.Time{}, errors.Errorf("didn't know how to interpret %v of %[1]T as time", valt)
|
|
}
|
|
}
|
|
|
|
func toUint64(val interface{}) (uint64, error) {
|
|
switch vt := val.(type) {
|
|
case uint:
|
|
return uint64(vt), nil
|
|
case uint8:
|
|
return uint64(vt), nil
|
|
case uint16:
|
|
return uint64(vt), nil
|
|
case uint32:
|
|
return uint64(vt), nil
|
|
case uint64:
|
|
return vt, nil
|
|
case int:
|
|
return uint64(vt), nil
|
|
case int8:
|
|
return uint64(vt), nil
|
|
case int16:
|
|
return uint64(vt), nil
|
|
case int32:
|
|
return uint64(vt), nil
|
|
case int64:
|
|
return uint64(vt), nil
|
|
case float64:
|
|
return uint64(vt), nil
|
|
case string:
|
|
v, err := strconv.ParseUint(strings.TrimSpace(vt), 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return v, nil
|
|
default:
|
|
return 0, errors.Errorf("couldn't convert %v of %[1]T to uint64", vt)
|
|
}
|
|
}
|
|
|
|
func toBool(val interface{}) (bool, error) {
|
|
switch vt := val.(type) {
|
|
case bool:
|
|
return vt, nil
|
|
case byte:
|
|
if vt == '0' || vt == 'f' || vt == 'F' {
|
|
return false, nil
|
|
}
|
|
return vt != 0, nil
|
|
case string:
|
|
switch strings.ToLower(vt) {
|
|
case "", "0", "f", "false":
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
default:
|
|
if vint, err := toInt64(val); err == nil {
|
|
return vint != 0, nil
|
|
}
|
|
return false, errors.Errorf("couldn't convert %v of %[1]T to bool", vt)
|
|
}
|
|
}
|
|
|
|
func toString(val interface{}) (string, error) {
|
|
switch vt := val.(type) {
|
|
case string:
|
|
return vt, nil
|
|
case []byte:
|
|
return string(vt), nil
|
|
default:
|
|
if vt == nil {
|
|
return "", nil
|
|
}
|
|
return fmt.Sprintf("%v", val), nil
|
|
}
|
|
}
|
|
|
|
func toInt64(val interface{}) (int64, error) {
|
|
switch vt := val.(type) {
|
|
case uint:
|
|
return int64(vt), nil
|
|
case uint8:
|
|
return int64(vt), nil
|
|
case uint16:
|
|
return int64(vt), nil
|
|
case uint32:
|
|
return int64(vt), nil
|
|
case uint64:
|
|
return int64(vt), nil
|
|
case int:
|
|
return int64(vt), nil
|
|
case int8:
|
|
return int64(vt), nil
|
|
case int16:
|
|
return int64(vt), nil
|
|
case int32:
|
|
return int64(vt), nil
|
|
case int64:
|
|
return vt, nil
|
|
case float32:
|
|
return int64(vt), nil
|
|
case float64:
|
|
return int64(vt), nil
|
|
case string: // added this case because of mysql driver sending the ids as strings
|
|
v, err := strconv.ParseInt(strings.TrimSpace(vt), 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return v, nil
|
|
default:
|
|
return 0, errors.Errorf("couldn't convert %v of %[1]T to int64", vt)
|
|
}
|
|
}
|
|
|
|
func toStringArray(val interface{}) ([]string, error) {
|
|
switch vt := val.(type) {
|
|
case []string:
|
|
return vt, nil
|
|
case []uint64:
|
|
ret := make([]string, len(vt))
|
|
for i, v := range vt {
|
|
ret[i] = strconv.FormatUint(v, 10)
|
|
}
|
|
return ret, nil
|
|
case map[uint64]struct{}:
|
|
arr, err := toUint64Array(val)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toStringArray(arr)
|
|
case string:
|
|
if vt == "" {
|
|
return nil, nil
|
|
}
|
|
if strings.HasPrefix(vt, "[") && strings.HasSuffix(vt, "]") {
|
|
vt = vt[1 : len(vt)-1]
|
|
}
|
|
vals := strings.Split(vt, ",")
|
|
return vals, nil
|
|
case []interface{}:
|
|
ret := make([]string, len(vt))
|
|
for i, v := range vt {
|
|
vs, ok := v.(string)
|
|
if !ok {
|
|
return nil, errors.Errorf("couldn't convert []interface{} to []string, value %v of type %[1]T at %d", v, i)
|
|
}
|
|
ret[i] = vs
|
|
}
|
|
return ret, nil
|
|
default:
|
|
return nil, errors.Errorf("couldn't convert %v of %[1]T to []string", vt)
|
|
}
|
|
}
|
|
|
|
func toUint64Array(val interface{}) ([]uint64, error) {
|
|
switch vt := val.(type) {
|
|
case []interface{}:
|
|
if len(vt) == 0 {
|
|
// Empty/nil set.
|
|
return nil, nil
|
|
}
|
|
arr := make([]uint64, len(vt))
|
|
for i := range vt {
|
|
vv, err := toUint64(vt[i])
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "non uint64 value in []interface{}: %v (%[1]T)", vt[i])
|
|
}
|
|
arr[i] = vv
|
|
}
|
|
return arr, nil
|
|
case map[uint64]struct{}:
|
|
if len(vt) == 0 {
|
|
// Empty/nil set.
|
|
return nil, nil
|
|
}
|
|
arr := make([]uint64, len(vt))
|
|
i := 0
|
|
for v := range vt {
|
|
arr[i] = v
|
|
i++
|
|
}
|
|
|
|
// Move the elements into a deterministic order.
|
|
sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] })
|
|
|
|
return arr, nil
|
|
case []uint64:
|
|
return vt, nil
|
|
case string:
|
|
if vt == "" {
|
|
return nil, nil
|
|
}
|
|
if strings.HasPrefix(vt, "[") && strings.HasSuffix(vt, "]") {
|
|
vt = vt[1 : len(vt)-1]
|
|
}
|
|
parts := strings.Split(vt, ",")
|
|
ret := make([]uint64, len(parts))
|
|
for i := range parts {
|
|
v, err := strconv.ParseUint(strings.TrimSpace(parts[i]), 10, 64)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "parsing uint64 from string: %s", vt)
|
|
}
|
|
ret[i] = v
|
|
}
|
|
return ret, nil
|
|
default:
|
|
return nil, errors.Errorf("couldn't convert %v of %[1]T to []uint64", vt)
|
|
}
|
|
}
|
|
|
|
// Fields is a list of Field, representing a schema.
|
|
type Fields []Field
|
|
|
|
// ContainsBool returns true if at least one field
|
|
// in the list is a BoolField.
|
|
func (f Fields) ContainsBool() bool {
|
|
for i := range f {
|
|
if _, ok := f[i].(BoolField); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|