mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
189 lines
4.2 KiB
Go
189 lines
4.2 KiB
Go
package csv
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/featurebasedb/featurebase/v3/idk"
|
|
"github.com/featurebasedb/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
type Source struct {
|
|
Files chan string
|
|
Header []string
|
|
IgnoreHeader bool
|
|
JustDoIt bool
|
|
Log logger.Logger
|
|
|
|
schemaLock sync.Mutex
|
|
schema []idk.Field
|
|
|
|
records chan Record
|
|
once *sync.Once
|
|
expectHeader bool
|
|
}
|
|
|
|
func (s *Source) Record() (idk.Record, error) {
|
|
s.once.Do(func() { go s.run() })
|
|
|
|
rec, ok := <-s.records
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return rec, rec.err
|
|
}
|
|
|
|
type Record struct {
|
|
data []interface{}
|
|
err error
|
|
}
|
|
|
|
func (r Record) Data() []interface{} {
|
|
return r.data
|
|
}
|
|
func (r Record) Commit(ctx context.Context) error { return nil } // TODO do
|
|
|
|
func (r Record) Schema() interface{} { return nil }
|
|
|
|
func (s *Source) Schema() []idk.Field {
|
|
s.schemaLock.Lock()
|
|
defer s.schemaLock.Unlock()
|
|
return s.schema
|
|
}
|
|
|
|
func (s *Source) run() {
|
|
defer close(s.records)
|
|
|
|
s.expectHeader = len(s.Header) == 0
|
|
if !s.expectHeader {
|
|
var err error
|
|
s.schemaLock.Lock()
|
|
s.schema, err = s.processHeader(s.Header)
|
|
s.schemaLock.Unlock()
|
|
if err != nil {
|
|
s.records <- Record{err: errors.Wrapf(err, "processing given header: %+v", s.Header)}
|
|
return
|
|
}
|
|
}
|
|
|
|
for filename := range s.Files {
|
|
s.processFile(filename)
|
|
}
|
|
}
|
|
|
|
func (s *Source) processHeader(header []string) (schema []idk.Field, err error) {
|
|
schema = make([]idk.Field, len(header))
|
|
for i, val := range header {
|
|
schema[i], err = idk.HeaderToField(val, s.Log)
|
|
if err != nil && s.JustDoIt {
|
|
schema[i] = idk.StringField{NameVal: strings.ToLower(val)}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
func (s *Source) processFile(name string) {
|
|
s.Log.Printf("processFile: %s", name)
|
|
|
|
f, err := openFileOrURL(name)
|
|
if err != nil {
|
|
s.records <- Record{err: errors.Wrapf(err, "opening %s", name)}
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
reader := csv.NewReader(f)
|
|
reader.ReuseRecord = true
|
|
reader.FieldsPerRecord = 0
|
|
var nextErr error
|
|
if s.expectHeader || s.IgnoreHeader {
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
s.records <- Record{err: errors.Wrapf(err, "reading header from '%s'", name)}
|
|
return
|
|
}
|
|
if s.expectHeader {
|
|
newschema, err := s.processHeader(header)
|
|
if err != nil {
|
|
s.Log.Printf("processHeader error: %v\n", err)
|
|
s.records <- Record{err: errors.Wrapf(err, "processing header from '%s': %+v", name, header)}
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(newschema, s.schema) {
|
|
s.schema = newschema
|
|
nextErr = idk.ErrSchemaChange
|
|
}
|
|
}
|
|
}
|
|
// weird hack to reuse memory rather than allocating eery
|
|
// time. Would probably be better to get rid of the channel and
|
|
// just the next CSV row on demand.
|
|
recs := [2]Record{
|
|
{data: make([]interface{}, len(s.schema)), err: nextErr},
|
|
{data: make([]interface{}, len(s.schema))},
|
|
}
|
|
i := -1
|
|
extraColumnsCount := 0
|
|
row, err := reader.Read()
|
|
for ; err == nil; row, err = reader.Read() {
|
|
i += 1
|
|
for j, val := range row {
|
|
if len(s.schema) <= j {
|
|
if extraColumnsCount == 0 {
|
|
s.Log.Warnf("'%s': ignoring additional column(s) not included in the header specification", name)
|
|
}
|
|
extraColumnsCount++
|
|
break
|
|
}
|
|
recs[i%2].data[j] = val
|
|
}
|
|
s.records <- recs[i%2]
|
|
recs[i%2].err = nil
|
|
}
|
|
if extraColumnsCount > 0 {
|
|
s.Log.Printf("Processing '%s': %d rows have more columns than header specification", name, extraColumnsCount)
|
|
}
|
|
if err != io.EOF {
|
|
s.Log.Printf("ERROR Processing '%s': '%v'. Skipping rest of file.", name, err)
|
|
}
|
|
}
|
|
|
|
func NewSource() *Source {
|
|
return &Source{
|
|
records: make(chan Record, 0), // nolint: gosimple // do not change buffer size!
|
|
}
|
|
}
|
|
|
|
func openFileOrURL(name string) (io.ReadCloser, error) {
|
|
var content io.ReadCloser
|
|
if strings.HasPrefix(name, "http") {
|
|
resp, err := http.Get(name)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getting via http")
|
|
}
|
|
if resp.StatusCode > 299 {
|
|
return nil, errors.Errorf("got status %d via http.Get", resp.StatusCode)
|
|
}
|
|
content = resp.Body
|
|
} else {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "opening file")
|
|
}
|
|
content = f
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func (s *Source) Close() error {
|
|
return nil
|
|
}
|