2020-08-06 20:58:47 +03:00
|
|
|
package userdriver
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
|
2020-08-23 13:42:15 +03:00
|
|
|
"github.com/neilotoole/sq/libsq/core/errz"
|
|
|
|
"github.com/neilotoole/sq/libsq/core/kind"
|
2024-01-25 10:42:51 +03:00
|
|
|
"github.com/neilotoole/sq/libsq/core/schema"
|
2020-08-23 13:42:15 +03:00
|
|
|
"github.com/neilotoole/sq/libsq/core/stringz"
|
2020-08-06 20:58:47 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// DriverDef is a user-defined driver definition.
|
|
|
|
type DriverDef struct {
|
|
|
|
// Name is short name of the driver type, e.g. "rss".
|
|
|
|
Name string `yaml:"driver" json:"driver"`
|
|
|
|
|
|
|
|
// Genre is the generic document type, e.g. XML or JSON etc.
|
|
|
|
Genre string `yaml:"genre" json:"genre"`
|
|
|
|
|
|
|
|
// Title is the full name of the driver
|
|
|
|
// type, e.g. "RSS (Really Simple Syndication)".
|
|
|
|
Title string `yaml:"title" json:"title"`
|
|
|
|
|
|
|
|
// Doc typically has a link to documentation for the driver..
|
|
|
|
Doc string `yaml:"doc,omitempty" json:"doc,omitempty"`
|
|
|
|
|
|
|
|
// Selector is the root doc element, e.g. "/rss"
|
|
|
|
Selector string `yaml:"selector" json:"selector"`
|
|
|
|
|
|
|
|
// Tables is the set of tables that define the type.
|
|
|
|
Tables []*TableMapping `yaml:"tables" json:"tables"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// TableBySelector returns the TableMapping that matches sel, or nil.
|
|
|
|
func (d *DriverDef) TableBySelector(sel string) *TableMapping {
|
|
|
|
for _, t := range d.Tables {
|
|
|
|
if t.Selector == sel {
|
|
|
|
return t
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *DriverDef) String() string {
|
|
|
|
return stringz.SprintJSON(d)
|
|
|
|
}
|
|
|
|
|
|
|
|
// TableMapping describes how document data is mapped to a table.
|
|
|
|
type TableMapping struct {
|
|
|
|
// Name is the table name.
|
|
|
|
Name string `yaml:"table" json:"table"`
|
|
|
|
|
|
|
|
// Selector specifies how the table data is selected.
|
|
|
|
Selector string `yaml:"selector" json:"selector"`
|
|
|
|
|
|
|
|
// Cols is the set of columns in the table.
|
|
|
|
Cols []*ColMapping `yaml:"cols" json:"cols"`
|
|
|
|
|
|
|
|
// PrimaryKey is a slice of the column names that constitute
|
|
|
|
// the primary key. Typically this is one column, but can be
|
|
|
|
// more than one for composite primary keys.
|
|
|
|
PrimaryKey []string `yaml:"primary_key" json:"primary_key"`
|
|
|
|
|
|
|
|
// Comment is an optional table comment.
|
|
|
|
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t *TableMapping) String() string {
|
|
|
|
return stringz.SprintJSON(t)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ColBySelector returns the ColMapping associated with the element, or nil if no such col.
|
|
|
|
func (t *TableMapping) ColBySelector(sel string) *ColMapping {
|
|
|
|
if sel == "" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, col := range t.Cols {
|
|
|
|
if sel == t.absColSelector(col) {
|
|
|
|
return col
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, col := range t.Cols {
|
|
|
|
if sel == t.Selector+"/"+col.Name {
|
|
|
|
return col
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// absColSelector returns an absolute (fully-qualified) selector for the provided ColMapping. For
|
|
|
|
// example, if ColMapping.Selector is "./item", the return value might be "/rss/channel/item".
|
|
|
|
func (t *TableMapping) absColSelector(col *ColMapping) string {
|
|
|
|
if col.Selector == "" {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
if col.Selector[0] == '/' {
|
|
|
|
return col.Selector
|
|
|
|
}
|
|
|
|
|
|
|
|
if strings.HasPrefix(col.Selector, "./") {
|
|
|
|
return t.Selector + col.Selector[1:]
|
|
|
|
}
|
|
|
|
|
|
|
|
return t.Selector + "/" + col.Selector
|
|
|
|
}
|
|
|
|
|
|
|
|
// PKCols returns the cols that constitute this table's primary key,
|
|
|
|
// or an error if none defined. If error is non-nil, the returned
|
|
|
|
// slice will contain at least one ColMapping.
|
|
|
|
func (t *TableMapping) PKCols() ([]*ColMapping, error) {
|
|
|
|
var cols []*ColMapping
|
|
|
|
|
|
|
|
for i := range t.Cols {
|
|
|
|
for j := range t.PrimaryKey {
|
|
|
|
if t.Cols[i].Name == t.PrimaryKey[j] {
|
|
|
|
cols = append(cols, t.Cols[i])
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(cols) == 0 {
|
2023-04-02 22:49:45 +03:00
|
|
|
return nil, errz.Errorf("no primary key column(s) defined for table {%s}", t.Name)
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
return cols, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// SequenceCols returns the cols whose selector value is "../sequence()".
|
|
|
|
// In effect, this method returns the columns for whom a sequence value
|
|
|
|
// should be set during a db insert, similar to a db auto-increment column.
|
|
|
|
func (t *TableMapping) SequenceCols() []*ColMapping {
|
|
|
|
var cols []*ColMapping
|
|
|
|
|
|
|
|
for i := range t.Cols {
|
|
|
|
if t.Cols[i].Selector == "../sequence()" {
|
|
|
|
cols = append(cols, t.Cols[i])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return cols
|
|
|
|
}
|
|
|
|
|
|
|
|
// RequiredCols returns the cols that are required. This includes columns
|
|
|
|
// with explicit ColMapping.Required field as well as other columns such
|
|
|
|
// as those part of the primary key or sequence cols.
|
|
|
|
func (t *TableMapping) RequiredCols() []*ColMapping {
|
|
|
|
var cols []*ColMapping
|
|
|
|
|
|
|
|
pkCols, _ := t.PKCols()
|
|
|
|
seqCols := t.SequenceCols()
|
|
|
|
|
|
|
|
for _, col := range t.Cols {
|
|
|
|
col := col
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case col.Required, colIndex(pkCols, col) >= 0, colIndex(seqCols, col) >= 0:
|
|
|
|
cols = append(cols, col)
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return cols
|
|
|
|
}
|
|
|
|
|
|
|
|
// ColMapping models a database table column.
|
|
|
|
type ColMapping struct {
|
|
|
|
// Name is the column name.
|
|
|
|
Name string `yaml:"col" json:"col"`
|
|
|
|
|
|
|
|
// Selector is an optional selector for the col value, e.g. "./guid/@isPermaLink" for an attribute of an XML element.
|
|
|
|
Selector string `yaml:"selector,omitempty" json:"selector,omitempty"`
|
|
|
|
|
|
|
|
// Kind is the data kind, e.g. "int", "text.
|
2020-08-23 13:42:15 +03:00
|
|
|
Kind kind.Kind `yaml:"kind" json:"kind"`
|
2020-08-06 20:58:47 +03:00
|
|
|
|
|
|
|
// Format is an optional type format for text values, e.g. "RFC3339" for a string.
|
|
|
|
Format string `yaml:"format,omitempty" json:"format,omitempty"`
|
|
|
|
|
|
|
|
// Charset is an optional charset for text values, e.g. "utf-8".
|
|
|
|
Charset string `yaml:"charset,omitempty" json:"charset,omitempty"`
|
|
|
|
|
|
|
|
// Foreign indicates that this column is a foreign key into a parent tbl.
|
|
|
|
Foreign string `yaml:"foreign,omitempty" json:"foreign,omitempty"`
|
|
|
|
|
|
|
|
// Unique is true if the column value is unique.
|
|
|
|
Unique bool `yaml:"unique,omitempty" json:"unique,omitempty"`
|
|
|
|
|
|
|
|
// Required is true if the column is required.
|
|
|
|
Required bool `yaml:"required" json:"required"`
|
|
|
|
|
|
|
|
// Comment is an optional column comment.
|
|
|
|
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *ColMapping) String() string {
|
|
|
|
return stringz.SprintJSON(c)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ValidateDriverDef checks that def is valid, returning one or
|
|
|
|
// more errors if not.
|
|
|
|
func ValidateDriverDef(def *DriverDef) []error {
|
|
|
|
drvrName, errs := validateDefRoot(def)
|
|
|
|
if len(errs) > 0 {
|
|
|
|
return errs
|
|
|
|
}
|
|
|
|
|
|
|
|
for i, tbl := range def.Tables {
|
|
|
|
tblName := fmt.Sprintf("%s.table[%d]", drvrName, i)
|
|
|
|
if tbl.Name == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s name is empty", tblName))
|
|
|
|
} else {
|
|
|
|
tblName = fmt.Sprintf("%s.table[%s]", drvrName, tbl.Name)
|
|
|
|
}
|
|
|
|
|
|
|
|
if tbl.Selector == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s selector is empty", tblName))
|
|
|
|
}
|
|
|
|
if len(tbl.Cols) == 0 {
|
|
|
|
errs = append(errs, errz.Errorf("%s cols is empty", tblName))
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(tbl.PrimaryKey) == 0 {
|
|
|
|
errs = append(errs, errz.Errorf("%s primary key must list at least one column", tblName))
|
|
|
|
} else {
|
|
|
|
for j, pkColName := range tbl.PrimaryKey {
|
|
|
|
if pkColName == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s primary key %d has empty name", tblName, j))
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// verify that the pk col exists in the cols
|
|
|
|
var foundIt bool
|
|
|
|
for k := range tbl.Cols {
|
|
|
|
if pkColName == tbl.Cols[k].Name {
|
|
|
|
foundIt = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !foundIt {
|
2022-12-18 09:42:11 +03:00
|
|
|
errs = append(errs,
|
2023-04-02 22:49:45 +03:00
|
|
|
errz.Errorf("{%s} specified primary key {%s} not found in cols", tblName, pkColName))
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for j, col := range tbl.Cols {
|
|
|
|
colName := fmt.Sprintf("%s.col[%d]", tblName, j)
|
|
|
|
if col.Name == "" {
|
2023-04-02 22:49:45 +03:00
|
|
|
errs = append(errs, errz.Errorf("{%s} name is empty", colName))
|
2020-08-06 20:58:47 +03:00
|
|
|
} else {
|
|
|
|
colName = fmt.Sprintf("%s.col[%s]", tblName, col.Name)
|
|
|
|
}
|
|
|
|
|
|
|
|
// These kinds are nonsensical
|
2022-12-18 09:42:11 +03:00
|
|
|
switch col.Kind { //nolint:exhaustive
|
2020-08-06 20:58:47 +03:00
|
|
|
default:
|
2020-08-23 13:42:15 +03:00
|
|
|
case kind.Unknown, kind.Null:
|
2023-04-02 22:49:45 +03:00
|
|
|
errs = append(errs, errz.Errorf("{%s}.kind {%s} is invalid", colName, col.Kind))
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return errs
|
|
|
|
}
|
|
|
|
|
|
|
|
func validateDefRoot(def *DriverDef) (drvrName string, errs []error) {
|
|
|
|
if def == nil {
|
|
|
|
// shouldn't happen
|
|
|
|
errs = append(errs, errz.New("def is nil"))
|
|
|
|
return "", errs
|
|
|
|
}
|
|
|
|
|
|
|
|
if def.Name == "" {
|
|
|
|
errs = append(errs, errz.New("driver name is empty"))
|
|
|
|
return "", errs
|
|
|
|
}
|
|
|
|
|
|
|
|
drvrName = fmt.Sprintf("driver[%s]", def.Name)
|
|
|
|
if def.Genre == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s.genre is empty", drvrName))
|
|
|
|
}
|
|
|
|
if def.Selector == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s.selector is empty", drvrName))
|
|
|
|
}
|
|
|
|
if def.Title == "" {
|
|
|
|
errs = append(errs, errz.Errorf("%s.title is empty", drvrName))
|
|
|
|
}
|
|
|
|
if len(def.Tables) == 0 {
|
|
|
|
errs = append(errs, errz.Errorf("%s.tables is empty", drvrName))
|
|
|
|
}
|
|
|
|
|
|
|
|
return drvrName, errs
|
|
|
|
}
|
|
|
|
|
|
|
|
// ToTableDef builds a TableDef from the TableMapping.
|
2024-01-25 10:42:51 +03:00
|
|
|
func ToTableDef(tblMapping *TableMapping) (*schema.Table, error) {
|
|
|
|
tblDef := &schema.Table{Name: tblMapping.Name}
|
|
|
|
colDefs := make([]*schema.Column, len(tblMapping.Cols))
|
2020-08-06 20:58:47 +03:00
|
|
|
|
|
|
|
pkCols, err := tblMapping.PKCols()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
tblDef.PKColName = pkCols[0].Name
|
|
|
|
|
|
|
|
for i, colMapping := range tblMapping.Cols {
|
2024-01-25 10:42:51 +03:00
|
|
|
colDef := &schema.Column{Table: tblDef, Name: colMapping.Name, Kind: colMapping.Kind}
|
2020-08-06 20:58:47 +03:00
|
|
|
colDefs[i] = colDef
|
|
|
|
}
|
|
|
|
|
|
|
|
tblDef.Cols = colDefs
|
|
|
|
return tblDef, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// NamesFromCols is a convenience function that returns a slice
|
|
|
|
// containing the name of each column.
|
|
|
|
func NamesFromCols(cols []*ColMapping) []string {
|
|
|
|
if cols == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
names := make([]string, len(cols))
|
|
|
|
for i := range cols {
|
|
|
|
names[i] = cols[i].Name
|
|
|
|
}
|
|
|
|
|
|
|
|
return names
|
|
|
|
}
|
|
|
|
|
|
|
|
// colIndex returns the index of needle in haystack, or -1.
|
|
|
|
func colIndex(haystack []*ColMapping, needle *ColMapping) int {
|
|
|
|
for i := range haystack {
|
|
|
|
if haystack[i] == needle {
|
|
|
|
return i
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return -1
|
|
|
|
}
|
|
|
|
|
|
|
|
// Detector defines a document type detector.
|
|
|
|
type Detector struct {
|
|
|
|
// Type is the detector type, e.g. "suffix", "header", "scheme", etc.
|
|
|
|
Type string `yaml:"type" json:"type"`
|
|
|
|
// Key is the expected match for the detector's key field. E.g. "Content-Type". May be empty.
|
|
|
|
Key string `yaml:"key,omitempty" json:"key,omitempty"`
|
|
|
|
// Value is the expected match for the detector's value field. E.g. "application/rss+xml"
|
|
|
|
Value string `yaml:"value" json:"value"`
|
|
|
|
// Example is an example value that would match the detector, e.g. "Content-Type: application/rss+xml"
|
|
|
|
Example string `yaml:"example,omitempty" json:"example,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *Detector) String() string {
|
|
|
|
return stringz.SprintJSON(d)
|
|
|
|
}
|