snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
3166
server/store/adapters/rdbms/aux_types.gen.go
generated
Normal file
3166
server/store/adapters/rdbms/aux_types.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
173
server/store/adapters/rdbms/config.go
Normal file
173
server/store/adapters/rdbms/config.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
ConnConfig struct {
|
||||
DriverName string
|
||||
DataSourceName string
|
||||
DBName string
|
||||
|
||||
// MaskedDSN is a DSN with sensitive data masked
|
||||
// each connection is handling that independently
|
||||
MaskedDSN string
|
||||
|
||||
// MaxOpenConns sets maximum number of open connections to the database
|
||||
// defaults to same value as set in the db/sql
|
||||
MaxOpenConns int
|
||||
|
||||
// ConnMaxLifetime sets the maximum amount of time a connection may be reused
|
||||
// defaults to same value as set in the db/sql
|
||||
ConnMaxLifetime time.Duration
|
||||
|
||||
// MaxIdleConns sets the maximum number of connections in the idle connection pool
|
||||
// defaults to same value as set in the db/sql
|
||||
MaxIdleConns int
|
||||
|
||||
// ConnTryPatience sets time window in which we do not complaining about failed connection tries
|
||||
ConnTryPatience time.Duration
|
||||
|
||||
// ConnTryBackoffDelay sets backoff delay after failed try
|
||||
ConnTryBackoffDelay time.Duration
|
||||
|
||||
// ConnTryTimeout sets timeout per try
|
||||
ConnTryTimeout time.Duration
|
||||
|
||||
// ConnTryMax maximum number of retries for getting the connection
|
||||
ConnTryMax int
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
dsnMasker = regexp.MustCompile(`(.)(?:.*)(.):(.)(?:.*)(.)@`)
|
||||
)
|
||||
|
||||
func (c *ConnConfig) SetDefaults() {
|
||||
if c.MaskedDSN == "" {
|
||||
c.MaskedDSN = c.DBName + "://********:********@********:********/********"
|
||||
}
|
||||
|
||||
if c.MaxIdleConns == 0 {
|
||||
// Same as default in the db/sql
|
||||
c.MaxIdleConns = 32
|
||||
}
|
||||
|
||||
if c.MaxOpenConns == 0 {
|
||||
// Same as default in the db/sql
|
||||
c.MaxOpenConns = 256
|
||||
}
|
||||
|
||||
if c.ConnMaxLifetime == 0 {
|
||||
// Same as default in the db/sql
|
||||
c.ConnMaxLifetime = 10 * time.Minute
|
||||
}
|
||||
|
||||
// ** ** ** ** ** ** ** ** ** ** ** ** ** **
|
||||
|
||||
if c.ConnTryPatience == 0 {
|
||||
//c.ConnTryPatience = 1 * time.Minute
|
||||
}
|
||||
|
||||
if c.ConnTryBackoffDelay == 0 {
|
||||
c.ConnTryBackoffDelay = 10 * time.Second
|
||||
}
|
||||
|
||||
if c.ConnTryTimeout == 0 {
|
||||
c.ConnTryTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
if c.ConnTryMax == 0 {
|
||||
c.ConnTryMax = 99
|
||||
}
|
||||
}
|
||||
|
||||
// ParseExtra parses extra params (params starting with *)
|
||||
// from DSN's querystring (after ?)
|
||||
func (c *ConnConfig) ParseExtra() (err error) {
|
||||
// Make sure we only got qs
|
||||
const q = "?"
|
||||
var (
|
||||
dsn = c.DataSourceName
|
||||
qs string
|
||||
)
|
||||
|
||||
if pos := strings.LastIndex(dsn, q); pos == -1 {
|
||||
return nil
|
||||
} else {
|
||||
// Trim qs from DSN, we'll re-attach the remaining params
|
||||
c.DataSourceName, qs = dsn[:pos], dsn[pos+1:]
|
||||
}
|
||||
|
||||
var vv url.Values
|
||||
if vv, err = url.ParseQuery(qs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
val string
|
||||
|
||||
parseInt = func(s string) (int, error) {
|
||||
if tmp, err := strconv.ParseInt(s, 10, 32); err != nil {
|
||||
return 0, err
|
||||
} else {
|
||||
return int(tmp), nil
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
const storePrefixChar = "*"
|
||||
|
||||
for key := range vv {
|
||||
val = vv.Get(key)
|
||||
|
||||
if storePrefixChar != key[:1] {
|
||||
// skip non-store specific config
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "*connTryPatience":
|
||||
c.ConnTryPatience, err = time.ParseDuration(val)
|
||||
|
||||
case "*connTryBackoffDelay":
|
||||
c.ConnTryBackoffDelay, err = time.ParseDuration(val)
|
||||
|
||||
case "*connTryTimeout":
|
||||
c.ConnTryTimeout, err = time.ParseDuration(val)
|
||||
|
||||
case "*connMaxTries":
|
||||
c.ConnTryMax, err = parseInt(val)
|
||||
|
||||
case "*connMaxOpen":
|
||||
c.MaxOpenConns, err = parseInt(val)
|
||||
|
||||
case "*connMaxLifetime":
|
||||
c.ConnMaxLifetime, err = time.ParseDuration(val)
|
||||
|
||||
case "*connMaxIdle":
|
||||
c.MaxIdleConns, err = parseInt(val)
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("unknown key %q", key)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid store configuration for key %q: %w", key, err)
|
||||
}
|
||||
|
||||
delete(vv, key)
|
||||
}
|
||||
|
||||
// Encode QS back to DSN
|
||||
c.DataSourceName += q + vv.Encode()
|
||||
|
||||
return nil
|
||||
}
|
||||
146
server/store/adapters/rdbms/connect.go
Normal file
146
server/store/adapters/rdbms/connect.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/sentry"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// TxRetryHardLimit is the absolute maximum retries we'll allow
|
||||
TxRetryHardLimit = 100
|
||||
|
||||
DefaultSliceCapacity = 1000
|
||||
|
||||
MinEnsureFetchLimit = 10
|
||||
|
||||
// Upper bound for the widening fetch window used when the check fn
|
||||
// rejects entire batches
|
||||
MaxEnsureFetchLimit = 1000
|
||||
MaxRefetches = 100
|
||||
|
||||
MaxLimit = 1000
|
||||
)
|
||||
|
||||
// Connect is rdbms' package connector
|
||||
//
|
||||
// Function is called from (store) driver's required connection function
|
||||
// to open connection and bind it with
|
||||
func Connect(ctx context.Context, log *zap.Logger, cfg *ConnConfig) (db *sqlx.DB, err error) {
|
||||
var (
|
||||
connErrCh = make(chan error, 1)
|
||||
patience = time.Now().Add(cfg.ConnTryPatience)
|
||||
base *sql.DB
|
||||
)
|
||||
|
||||
log = log.Named("store")
|
||||
|
||||
if base, err = sql.Open(cfg.DriverName, cfg.DataSourceName); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
db = sqlx.NewDb(base, cfg.DriverName)
|
||||
log.Debug(
|
||||
"setting database connection parameters",
|
||||
zap.Int("MaxOpenConns", cfg.MaxOpenConns),
|
||||
zap.Duration("MaxLifetime", cfg.ConnMaxLifetime),
|
||||
zap.Int("MaxIdleConns", cfg.MaxIdleConns),
|
||||
|
||||
// log DSN with masked username and password
|
||||
zap.String("DSN", cfg.MaskedDSN),
|
||||
)
|
||||
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
|
||||
go func() {
|
||||
defer sentry.Recover()
|
||||
|
||||
var (
|
||||
err error
|
||||
try = 0
|
||||
|
||||
// Make a small adjustment when
|
||||
// collecting callers from the callstack for this
|
||||
log = log.WithOptions(zap.AddCallerSkip(-2))
|
||||
)
|
||||
|
||||
for {
|
||||
try++
|
||||
|
||||
if cfg.ConnTryMax <= try {
|
||||
connErrCh <- fmt.Errorf("could not connect in %d tries", try)
|
||||
return
|
||||
}
|
||||
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
|
||||
if time.Now().After(patience) {
|
||||
// don't make too much fuss
|
||||
// if we're in patience mode
|
||||
log.Warn(
|
||||
"could not connect to the database",
|
||||
zap.Error(err),
|
||||
zap.Int("try", try),
|
||||
zap.Float64("delay", cfg.ConnTryBackoffDelay.Seconds()),
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Forced break
|
||||
break
|
||||
case <-time.After(cfg.ConnTryBackoffDelay):
|
||||
// Wait before next try
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("connected to the database")
|
||||
break
|
||||
}
|
||||
|
||||
connErrCh <- err
|
||||
}()
|
||||
|
||||
to := cfg.ConnTryTimeout * time.Duration(cfg.ConnTryMax*2)
|
||||
select {
|
||||
case err = <-connErrCh:
|
||||
return
|
||||
case <-time.After(to):
|
||||
// Wait before next try
|
||||
return nil, fmt.Errorf("timedout after %.2fs", to.Seconds())
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("connection cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// Connect is called from the adapter's Connect function
|
||||
//
|
||||
// It is intentionally not compatible with store.ConnectorFn
|
||||
// and can not be used to register
|
||||
//func Connect(ctx context.Context, cfg *ConnConfig) (s *Store, err error) {
|
||||
// return s, func() error {
|
||||
// if err = cfg.ParseExtra(); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// cfg.SetDefaults()
|
||||
// s = &Store{
|
||||
// config: cfg,
|
||||
// //schema: ddl.SchemaAPI(s.DB(), ddl.NewCommonDialect()),
|
||||
// }
|
||||
//
|
||||
// return s.Connect(ctx)
|
||||
// }()
|
||||
//}
|
||||
|
||||
func dbHealthcheck(db *sqlx.DB) func(ctx context.Context) error {
|
||||
return db.PingContext
|
||||
}
|
||||
359
server/store/adapters/rdbms/cursor.go
Normal file
359
server/store/adapters/rdbms/cursor.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
cursorCondition struct {
|
||||
cur pagingCursor
|
||||
keyMapper cursorKeyMapper
|
||||
sortableCols map[string]string
|
||||
}
|
||||
|
||||
pagingCursor interface {
|
||||
Keys() []string
|
||||
KK() [][]string
|
||||
Modifiers() []string
|
||||
Values() []interface{}
|
||||
IsLThen() bool
|
||||
Desc() []bool
|
||||
IsROrder() bool
|
||||
}
|
||||
|
||||
KeyMap struct {
|
||||
FieldCast string
|
||||
TypeCast string
|
||||
TypeCastPtrn string
|
||||
}
|
||||
|
||||
// translates field to (store) column
|
||||
// this helps us with upper/lower case, underscore/camel-case and
|
||||
// when using fields on records
|
||||
//
|
||||
// @todo extend the return args to provide additional info (like is-nullable)
|
||||
// to avoid IS NULL checks (see sql() fn)
|
||||
cursorKeyMapper func(string) (KeyMap, error)
|
||||
)
|
||||
|
||||
func cursor(cursor *filter.PagingCursor) ([]goqu.Expression, error) {
|
||||
sql, args, err := CursorCondition(cursor, nil, nil).ToSQL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []goqu.Expression{goqu.Literal(sql, args...)}, nil
|
||||
}
|
||||
|
||||
func cursorWithSorting(cursor *filter.PagingCursor, sortableCols map[string]string) ([]goqu.Expression, error) {
|
||||
sql, args, err := CursorCondition(cursor, nil, sortableCols).ToSQL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []goqu.Expression{goqu.Literal(sql, args...)}, nil
|
||||
}
|
||||
|
||||
// CursorCondition builds a complex condition to filter rows before/after row that
|
||||
// the paging cursor points to
|
||||
func CursorCondition(pc pagingCursor, keyMapper cursorKeyMapper, sortableCols map[string]string) *cursorCondition {
|
||||
if keyMapper == nil {
|
||||
keyMapper = func(s string) (KeyMap, error) {
|
||||
return KeyMap{
|
||||
FieldCast: s,
|
||||
TypeCast: s,
|
||||
TypeCastPtrn: "%s",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &cursorCondition{cur: pc, keyMapper: keyMapper, sortableCols: sortableCols}
|
||||
}
|
||||
|
||||
func (c *cursorCondition) ToSQL() (string, []interface{}, error) {
|
||||
sql, err := c.sql()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return sql, c.values(), nil
|
||||
}
|
||||
|
||||
// generates all values that we need for the generated condition SQL
|
||||
func (c *cursorCondition) values() []interface{} {
|
||||
if len(c.cur.Keys()) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
l := len(c.cur.Values())
|
||||
vv := make([]interface{}, 0, l*2-1)
|
||||
for i, v := range c.cur.Values() {
|
||||
vv = append(vv, v)
|
||||
if i < l-1 {
|
||||
// for all but 1st, use values 2 times.
|
||||
vv = append(vv, v)
|
||||
}
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
// builds cursor SQL expression
|
||||
//
|
||||
// this could be simple (f1, f2, ...) < (v1, v2, ...) but we need to be a bit careful with NULL values
|
||||
// So we need (f1 < v1 OR (f1 = v1 AND f2 < v2) pattern, extended to:
|
||||
// ((f1 IS NULL AND v1 IS NOT NULL) OR f1 < v1 OR (((f1 IS NULL AND v1 IS NULL) OR f1 = v1) AND (f2...)
|
||||
//
|
||||
// Due to issues with param biding & types in Postgres (using ? IS NULL results in an error), we need do
|
||||
// check (on app-side) if value is nil to replace "? IS (NOT) NULL" check with TRUE/FALSE constants.
|
||||
func (c *cursorCondition) sql() (cnd string, err error) {
|
||||
const (
|
||||
// we start with this
|
||||
baseTpl = "((%s IS %s AND %s) OR (%s %s %s))"
|
||||
|
||||
// and then wrap each iteration with base and this
|
||||
wrapTpl = "(%s OR (((%s IS NULL AND %s) OR %s = %s) AND %s))"
|
||||
)
|
||||
|
||||
var (
|
||||
cc = c.cur.Keys()
|
||||
kk = c.cur.KK()
|
||||
mm = c.cur.Modifiers()
|
||||
vv = c.cur.Values()
|
||||
|
||||
ltOp = map[bool]string{
|
||||
true: "<",
|
||||
false: ">",
|
||||
}
|
||||
|
||||
notOp = map[bool]string{
|
||||
true: "NOT NULL",
|
||||
false: "NULL",
|
||||
}
|
||||
|
||||
// Little utility to know for sure if some value is nil or not
|
||||
//
|
||||
// Interface variables can be a bit tricky here, so this is required.
|
||||
nilCheck = func(i interface{}) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
switch reflect.TypeOf(i).Kind() {
|
||||
case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
|
||||
return reflect.ValueOf(i).IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Modifying this function to use expressions instead of constant boolean
|
||||
// values because MSSQL doesn't have those.
|
||||
//
|
||||
// @todo rethink and redo the whole/all of the filtering logic surrounding paging
|
||||
// cursors to make them consistent/reusable
|
||||
isNull = func(i int, neg bool) string {
|
||||
if (nilCheck(vv[i]) && !neg) || (!nilCheck(vv[i]) && neg) {
|
||||
return "1=1"
|
||||
}
|
||||
|
||||
return "1=0"
|
||||
}
|
||||
)
|
||||
|
||||
if len(cc) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// going from the last key/column to the 1st one
|
||||
for i := len(cc) - 1; i >= 0; i-- {
|
||||
// Get the key context so we know how to format fields and format typecasts
|
||||
colName := cc[i]
|
||||
if c.sortableCols != nil {
|
||||
if v, ok := c.sortableCols[strings.ToLower(cc[i])]; ok {
|
||||
colName = v
|
||||
}
|
||||
|
||||
// update column name as per the modifier if needed
|
||||
if len(kk[i]) > 0 && strings.ToLower(mm[i]) == filter.COALESCE {
|
||||
var tmp []string
|
||||
for _, k := range kk[i] {
|
||||
if v, ok := c.sortableCols[strings.ToLower(k)]; ok {
|
||||
tmp = append(tmp, v)
|
||||
}
|
||||
}
|
||||
colName = fmt.Sprintf("COALESCE(%s)", strings.Join(tmp, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
km, err := c.keyMapper(colName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// We need to cut off the values that are before the cursor (when ascending)
|
||||
// and vice-versa for descending.
|
||||
lt := c.cur.Desc()[i]
|
||||
if c.cur.IsROrder() {
|
||||
lt = !lt
|
||||
}
|
||||
op := ltOp[lt]
|
||||
|
||||
// Typecast the value so comparasement can work properly
|
||||
vc := fmt.Sprintf(km.TypeCastPtrn, "?")
|
||||
|
||||
base := fmt.Sprintf(baseTpl, km.FieldCast, notOp[!lt], isNull(i, lt), km.TypeCast, op, vc)
|
||||
|
||||
if cnd == "" {
|
||||
cnd = base
|
||||
} else {
|
||||
// wrap existing conditions (next key) and the generated base for the current key
|
||||
cnd = fmt.Sprintf(wrapTpl, base, km.FieldCast, isNull(i, false), km.TypeCast, vc, cnd)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// CursorExpression builds cursor SQL expression using goqu/exp package
|
||||
//
|
||||
// this could be simple (f1, f2, ...) < (v1, v2, ...) but we need to be a bit careful with NULL values
|
||||
// So we need (f1 < v1 OR (f1 = v1 AND f2 < v2) pattern, extended to:
|
||||
// ((f1 IS NULL AND v1 IS NOT NULL) OR f1 < v1 OR (((f1 IS NULL AND v1 IS NULL) OR f1 = v1) AND (f2...)
|
||||
//
|
||||
// Due to issues with param biding & types in Postgres (using ? IS NULL results in an error), we need do
|
||||
// check (on app-side) if value is nil to replace "? IS (NOT) NULL" check with TRUE/FALSE constants.
|
||||
func CursorExpression(
|
||||
cur *filter.PagingCursor,
|
||||
identLookup func(i string) (exp.Expression, error),
|
||||
castFn func(i string, val any) (exp.Expression, error),
|
||||
) (e exp.Expression, err error) {
|
||||
var (
|
||||
cc = cur.Keys()
|
||||
vv = cur.Values()
|
||||
|
||||
value any
|
||||
|
||||
ident exp.Expression
|
||||
|
||||
ltOp = map[bool]exp.BooleanOperation{
|
||||
true: exp.LtOp,
|
||||
false: exp.GtOp,
|
||||
}
|
||||
|
||||
notOp = map[bool]exp.LiteralExpression{
|
||||
true: exp.NewLiteralExpression("NOT NULL"),
|
||||
false: exp.NewLiteralExpression("NULL"),
|
||||
}
|
||||
|
||||
// Little utility to know for sure if some value is nil or not
|
||||
//
|
||||
// Interface variables can be a bit tricky here, so this is required.
|
||||
nilCheck = func(i interface{}) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
switch reflect.TypeOf(i).Kind() {
|
||||
case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
|
||||
return reflect.ValueOf(i).IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Modifying this function to use expressions instead of constant boolean
|
||||
// values because MSSQL doesn't have those.
|
||||
//
|
||||
// @todo rethink and redo the whole/all of the filtering logic surrounding paging
|
||||
// cursors to make them consistent/reusable
|
||||
isValueNull = func(i int, neg bool) exp.Expression {
|
||||
if (nilCheck(vv[i]) && !neg) || (!nilCheck(vv[i]) && neg) {
|
||||
return exp.NewLiteralExpression("1=1")
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("1=0")
|
||||
}
|
||||
|
||||
curCond exp.ExpressionList
|
||||
)
|
||||
|
||||
if len(cc) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// ((($1 IS NOT NULL AND FALSE) OR ($2 > $3)) OR (((? IS NULL AND FALSE) OR ? = ?) AND (("test_tbl"."id" IS NOT NULL AND FALSE) OR ("test_tbl"."id" > ?))))
|
||||
|
||||
// going from the last key/column to the 1st one
|
||||
for i := len(cc) - 1; i >= 0; i-- {
|
||||
if identLookup != nil {
|
||||
// Get the key context so we know how to format fields and format typecasts
|
||||
ident, err = identLookup(cc[i])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ident = exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", "", cc[i]))
|
||||
}
|
||||
|
||||
if castFn == nil {
|
||||
value = vv[i]
|
||||
} else {
|
||||
value, err = castFn(cc[i], vv[i])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We need to cut off the values that are before the cursor (when ascending)
|
||||
// and vice-versa for descending.
|
||||
lt := cur.Desc()[i]
|
||||
if cur.IsROrder() {
|
||||
lt = !lt
|
||||
}
|
||||
|
||||
op := ltOp[lt]
|
||||
|
||||
// // Typecast the value so comparison can work properly
|
||||
|
||||
// Either BOTH (field and value) are NULL or field is grater-then value
|
||||
base := exp.NewExpressionList(
|
||||
exp.OrType,
|
||||
// both NULL
|
||||
exp.NewExpressionList(
|
||||
exp.AndType,
|
||||
exp.NewLiteralExpression(`(? IS ?)`, ident, notOp[!lt]),
|
||||
isValueNull(i, lt),
|
||||
),
|
||||
// or GT/LT value
|
||||
exp.NewBooleanExpression(op, ident, value),
|
||||
)
|
||||
|
||||
if curCond == nil {
|
||||
curCond = base
|
||||
} else {
|
||||
curCond = exp.NewExpressionList(
|
||||
exp.OrType,
|
||||
base,
|
||||
exp.NewExpressionList(
|
||||
exp.AndType,
|
||||
exp.NewExpressionList(
|
||||
exp.OrType,
|
||||
// both NULL
|
||||
exp.NewExpressionList(
|
||||
exp.AndType,
|
||||
exp.NewLiteralExpression(`(? IS NULL)`, ident),
|
||||
isValueNull(i, false),
|
||||
),
|
||||
exp.NewBooleanExpression(exp.EqOp, ident, value),
|
||||
),
|
||||
curCond,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return curCond.Expression(), nil
|
||||
}
|
||||
104
server/store/adapters/rdbms/cursors_test.go
Normal file
104
server/store/adapters/rdbms/cursors_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_buildCursorCond(t *testing.T) {
|
||||
tests := []struct {
|
||||
cursor *filter.PagingCursor
|
||||
sql string
|
||||
esql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", 1, false)
|
||||
return c
|
||||
}(),
|
||||
"((f1 IS NOT NULL AND FALSE) OR (f1 > ?))",
|
||||
`((("f1" IS NOT NULL) AND FALSE) OR ("f1" > ?))`,
|
||||
[]any{1},
|
||||
},
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", 2, false)
|
||||
c.Set("f2", 3, false)
|
||||
return c
|
||||
}(),
|
||||
"(((f1 IS NOT NULL AND FALSE) OR (f1 > ?)) OR (((f1 IS NULL AND FALSE) OR f1 = ?) AND ((f2 IS NOT NULL AND FALSE) OR (f2 > ?))))",
|
||||
`(((("f1" IS NOT NULL) AND FALSE) OR ("f1" > ?)) OR (((("f1" IS NULL) AND FALSE) OR ("f1" = ?)) AND ((("f2" IS NOT NULL) AND FALSE) OR ("f2" > ?))))`,
|
||||
[]any{2, 2, 3},
|
||||
},
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", 4, false)
|
||||
c.LThen = true
|
||||
return c
|
||||
}(),
|
||||
"((f1 IS NOT NULL AND FALSE) OR (f1 > ?))",
|
||||
`((("f1" IS NOT NULL) AND FALSE) OR ("f1" > ?))`,
|
||||
[]any{4},
|
||||
},
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", 5, false)
|
||||
c.Set("f2", 6, false)
|
||||
c.LThen = true
|
||||
return c
|
||||
}(),
|
||||
"(((f1 IS NOT NULL AND FALSE) OR (f1 > ?)) OR (((f1 IS NULL AND FALSE) OR f1 = ?) AND ((f2 IS NOT NULL AND FALSE) OR (f2 > ?))))",
|
||||
`(((("f1" IS NOT NULL) AND FALSE) OR ("f1" > ?)) OR (((("f1" IS NULL) AND FALSE) OR ("f1" = ?)) AND ((("f2" IS NOT NULL) AND FALSE) OR ("f2" > ?))))`,
|
||||
[]any{5, 5, 6},
|
||||
},
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", 7, false)
|
||||
c.Set("f2", nil, false)
|
||||
return c
|
||||
}(),
|
||||
"(((f1 IS NOT NULL AND FALSE) OR (f1 > ?)) OR (((f1 IS NULL AND FALSE) OR f1 = ?) AND ((f2 IS NOT NULL AND TRUE) OR (f2 > ?))))",
|
||||
`(((("f1" IS NOT NULL) AND FALSE) OR ("f1" > ?)) OR (((("f1" IS NULL) AND FALSE) OR ("f1" = ?)) AND ((("f2" IS NOT NULL) AND TRUE) OR ("f2" > ?))))`,
|
||||
[]any{7, 7, nil},
|
||||
},
|
||||
{
|
||||
func() *filter.PagingCursor {
|
||||
c := &filter.PagingCursor{}
|
||||
c.Set("f1", nil, false)
|
||||
c.Set("f2", 8, false)
|
||||
return c
|
||||
}(),
|
||||
"(((f1 IS NOT NULL AND TRUE) OR (f1 > ?)) OR (((f1 IS NULL AND TRUE) OR f1 = ?) AND ((f2 IS NOT NULL AND FALSE) OR (f2 > ?))))",
|
||||
`(((("f1" IS NOT NULL) AND TRUE) OR ("f1" > ?)) OR (((("f1" IS NULL) AND TRUE) OR ("f1" = ?)) AND ((("f2" IS NOT NULL) AND FALSE) OR ("f2" > ?))))`,
|
||||
[]any{nil, nil, 8},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.cursor.String(), func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
sql, args, err = CursorCondition(tt.cursor, nil, nil).ToSQL()
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal(tt.sql, sql)
|
||||
req.Equal(tt.args, args)
|
||||
|
||||
ee, err := CursorExpression(tt.cursor, nil, nil)
|
||||
req.NoError(err)
|
||||
sql, args, err = goqu.Dialect("sqlite3").Select().Where(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
req.Equal(tt.esql, sql[15:])
|
||||
})
|
||||
}
|
||||
}
|
||||
84
server/store/adapters/rdbms/custom_applications.go
Normal file
84
server/store/adapters/rdbms/custom_applications.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
systemType "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) ApplicationMetrics(ctx context.Context) (_ *systemType.ApplicationMetrics, err error) {
|
||||
var (
|
||||
aux = struct {
|
||||
Total uint `db:"total"`
|
||||
Deleted uint `db:"deleted"`
|
||||
Valid uint `db:"valid"`
|
||||
}{}
|
||||
|
||||
query = applicationSelectQuery(s.Dialect.GOQU()).
|
||||
Select(timestampStatExpr("deleted")...)
|
||||
)
|
||||
|
||||
if err = s.QueryOne(ctx, query, &aux); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &systemType.ApplicationMetrics{
|
||||
Total: aux.Total,
|
||||
Deleted: aux.Deleted,
|
||||
Valid: aux.Valid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Store) ReorderApplications(ctx context.Context, order []uint64) (err error) {
|
||||
var (
|
||||
apps systemType.ApplicationSet
|
||||
appMap = map[uint64]bool{}
|
||||
weight = 1
|
||||
|
||||
f = systemType.ApplicationFilter{}
|
||||
|
||||
query = func(id uint64, weight int) *goqu.UpdateDataset {
|
||||
return s.Dialect.GOQU().
|
||||
Update(applicationTable).
|
||||
Set(goqu.Record{"weight": weight}).
|
||||
Where(goqu.C("id").Eq(id))
|
||||
}
|
||||
)
|
||||
|
||||
if apps, _, err = s.SearchApplications(ctx, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, app := range apps {
|
||||
appMap[app.ID] = true
|
||||
}
|
||||
|
||||
// honor parameter first
|
||||
for _, id := range order {
|
||||
if appMap[id] {
|
||||
appMap[id] = false
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
}
|
||||
}
|
||||
|
||||
for id, update := range appMap {
|
||||
if !update {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
28
server/store/adapters/rdbms/custom_auth_oa2tokens.go
Normal file
28
server/store/adapters/rdbms/custom_auth_oa2tokens.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) DeleteExpiredAuthOA2Tokens(ctx context.Context) error {
|
||||
return s.Exec(ctx, authOa2tokenDeleteQuery(s.Dialect.GOQU(), goqu.C("expires_at").Lt(time.Now())))
|
||||
}
|
||||
|
||||
func (s Store) DeleteAuthOA2TokenByCode(ctx context.Context, code string) error {
|
||||
return s.Exec(ctx, authOa2tokenDeleteQuery(s.Dialect.GOQU(), goqu.C("code").Eq(code)))
|
||||
}
|
||||
|
||||
func (s Store) DeleteAuthOA2TokenByAccess(ctx context.Context, access string) error {
|
||||
return s.Exec(ctx, authOa2tokenDeleteQuery(s.Dialect.GOQU(), goqu.C("access").Eq(access)))
|
||||
}
|
||||
|
||||
func (s Store) DeleteAuthOA2TokenByRefresh(ctx context.Context, refresh string) error {
|
||||
return s.Exec(ctx, authOa2tokenDeleteQuery(s.Dialect.GOQU(), goqu.C("refresh").Eq(refresh)))
|
||||
}
|
||||
|
||||
func (s Store) DeleteAuthOA2TokenByUserID(ctx context.Context, userID uint64) error {
|
||||
return s.Exec(ctx, authOa2tokenDeleteQuery(s.Dialect.GOQU(), goqu.C("rel_user").Eq(userID)))
|
||||
}
|
||||
16
server/store/adapters/rdbms/custom_auth_sessions.go
Normal file
16
server/store/adapters/rdbms/custom_auth_sessions.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) DeleteExpiredAuthSessions(ctx context.Context) error {
|
||||
return s.Exec(ctx, authSessionDeleteQuery(s.Dialect.GOQU(), goqu.C("expires_at").Lt(time.Now())))
|
||||
}
|
||||
|
||||
func (s Store) DeleteAuthSessionsByUserID(ctx context.Context, userID uint64) error {
|
||||
return s.Exec(ctx, authSessionDeleteQuery(s.Dialect.GOQU(), goqu.C("rel_user").Eq(userID)))
|
||||
}
|
||||
61
server/store/adapters/rdbms/custom_compose_page_layouts.go
Normal file
61
server/store/adapters/rdbms/custom_compose_page_layouts.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
composeType "github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) ReorderComposePageLayouts(ctx context.Context, namespaceID uint64, pageID uint64, pageLayoutLayoutIDs []uint64) (err error) {
|
||||
var (
|
||||
pageLayouts composeType.PageLayoutSet
|
||||
pageLayoutMap = map[uint64]bool{}
|
||||
weight = 1
|
||||
|
||||
f = composeType.PageLayoutFilter{PageID: pageID, NamespaceID: namespaceID}
|
||||
|
||||
query = func(id uint64, weight int) *goqu.UpdateDataset {
|
||||
return s.Dialect.GOQU().
|
||||
Update(composePageLayoutTable).
|
||||
Set(goqu.Record{"weight": weight}).
|
||||
Where(goqu.C("id").Eq(id))
|
||||
}
|
||||
)
|
||||
|
||||
if pageLayouts, _, err = s.SearchComposePageLayouts(ctx, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, app := range pageLayouts {
|
||||
pageLayoutMap[app.ID] = true
|
||||
}
|
||||
|
||||
// honor parameter first
|
||||
for _, id := range pageLayoutLayoutIDs {
|
||||
if pageLayoutMap[id] {
|
||||
pageLayoutMap[id] = false
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
}
|
||||
}
|
||||
|
||||
for id, update := range pageLayoutMap {
|
||||
if !update {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
61
server/store/adapters/rdbms/custom_compose_pages.go
Normal file
61
server/store/adapters/rdbms/custom_compose_pages.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
composeType "github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) ReorderComposePages(ctx context.Context, namespaceID uint64, parentID uint64, pageIDs []uint64) (err error) {
|
||||
var (
|
||||
pages composeType.PageSet
|
||||
pageMap = map[uint64]bool{}
|
||||
weight = 1
|
||||
|
||||
f = composeType.PageFilter{ParentID: parentID, NamespaceID: namespaceID}
|
||||
|
||||
query = func(id uint64, weight int) *goqu.UpdateDataset {
|
||||
return s.Dialect.GOQU().
|
||||
Update(composePageTable).
|
||||
Set(goqu.Record{"weight": weight}).
|
||||
Where(goqu.C("id").Eq(id))
|
||||
}
|
||||
)
|
||||
|
||||
if pages, _, err = s.SearchComposePages(ctx, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, app := range pages {
|
||||
pageMap[app.ID] = true
|
||||
}
|
||||
|
||||
// honor parameter first
|
||||
for _, id := range pageIDs {
|
||||
if pageMap[id] {
|
||||
pageMap[id] = false
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
}
|
||||
}
|
||||
|
||||
for id, update := range pageMap {
|
||||
if !update {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = s.Exec(ctx, query(id, weight)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
weight++
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
22
server/store/adapters/rdbms/custom_labels.go
Normal file
22
server/store/adapters/rdbms/custom_labels.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) DeleteExtraLabels(ctx context.Context, kind string, resourceId uint64, name ...string) error {
|
||||
var (
|
||||
expr = []goqu.Expression{
|
||||
goqu.C("kind").Eq(kind),
|
||||
goqu.C("rel_resource").Eq(resourceId),
|
||||
}
|
||||
)
|
||||
|
||||
if len(name) > 0 {
|
||||
expr = append(expr, goqu.C("name").NotIn(name))
|
||||
}
|
||||
|
||||
return s.Exec(ctx, labelDeleteQuery(s.Dialect.GOQU(), expr...))
|
||||
}
|
||||
112
server/store/adapters/rdbms/custom_metrics.go
Normal file
112
server/store/adapters/rdbms/custom_metrics.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
sqlx "github.com/jmoiron/sqlx/types"
|
||||
)
|
||||
|
||||
// A temporary alias for old type that was used handle JSON encoded data
|
||||
type rawJson = sqlx.JSONText
|
||||
|
||||
// multiDailyMetrics simplifies fetching of multiple daily metrics
|
||||
//
|
||||
// This function is copied from old repository and adapted to work under store,
|
||||
// might be good to refactor it to fit into this store pattern a bit more
|
||||
func (s Store) multiDailyMetrics(ctx context.Context, tbl exp.IdentifierExpression, expr []goqu.Expression, fields []string, mm ...*[]uint) (err error) {
|
||||
for m := 0; m < len(mm); m++ {
|
||||
*mm[m], err = s.dailyMetrics(ctx, tbl, expr, fields[m])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// dailyMetrics aids repositories on simple stat building queries
|
||||
//
|
||||
// Returns a slice of numbers (timestamp + value pairs)
|
||||
//
|
||||
// This function is copied from old repository and adapted to work under store,
|
||||
// might be good to refactor it to fit into this store pattern a bit more
|
||||
func (s Store) dailyMetrics(ctx context.Context, tbl exp.IdentifierExpression, expr []goqu.Expression, field string) ([]uint, error) {
|
||||
const format = "2006-01-02"
|
||||
|
||||
var (
|
||||
rval = make([]uint, 0)
|
||||
|
||||
d time.Time
|
||||
ts string
|
||||
c uint
|
||||
|
||||
rows *sql.Rows
|
||||
)
|
||||
|
||||
return rval, func() (err error) {
|
||||
daily, err := s.Dialect.AttributeCast(&dal.Attribute{Type: dal.TypeDate{}}, goqu.C(field))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
query := s.Dialect.GOQU().
|
||||
Select(daily, goqu.COUNT(goqu.Star()).As("value")).
|
||||
From(tbl).
|
||||
Where(exp.NewLiteralExpression("? IS NOT NULL", goqu.C(field)), goqu.And(expr...)).
|
||||
GroupBy(daily).
|
||||
Order(exp.NewOrderedExpression(daily, exp.AscDir, exp.NoNullsSortType))
|
||||
|
||||
if rows, err = s.Query(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if err = rows.Scan(&ts, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d = time.Time{}
|
||||
if len(ts) >= len(format) {
|
||||
if d, err = time.Parse(format, ts[0:len(format)]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rval = append(rval, uint(d.Unix()), c)
|
||||
}
|
||||
|
||||
return
|
||||
}()
|
||||
}
|
||||
|
||||
// Assembles select expression that calculates total + each given ts field + valid
|
||||
//
|
||||
func timestampStatExpr(fields ...string) []interface{} {
|
||||
var (
|
||||
valid = []goqu.Expression{}
|
||||
ee = []interface{}{goqu.COUNT(goqu.Star()).As("total")}
|
||||
|
||||
// literal 0 and 1 values we can safely use in the query
|
||||
lit0, lit1 = goqu.L("0"), goqu.L("1")
|
||||
|
||||
sum = func(field string) goqu.Expression {
|
||||
return goqu.COALESCE(
|
||||
goqu.SUM(goqu.Case().When(exp.NewLiteralExpression("? IS NOT NULL", goqu.C(field+"_at")), lit1).Else(lit0)),
|
||||
lit0,
|
||||
).As(field)
|
||||
}
|
||||
)
|
||||
|
||||
for _, field := range fields {
|
||||
ee = append(ee, sum(field))
|
||||
valid = append(valid, exp.NewLiteralExpression("? IS NULL", goqu.C(field+"_at")))
|
||||
}
|
||||
|
||||
return append(ee, goqu.SUM(goqu.Case().When(goqu.And(valid...), lit1).Else(lit0)).As("valid"))
|
||||
}
|
||||
37
server/store/adapters/rdbms/custom_metrics_test.go
Normal file
37
server/store/adapters/rdbms/custom_metrics_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_timestampStats(t *testing.T) {
|
||||
tests := []struct {
|
||||
fields []string
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
fields: []string{"a"},
|
||||
sql: `SELECT COUNT(*), SUM(CASE WHEN ("a_at" IS NOT NULL) THEN 1 ELSE 0 END) AS "a", SUM(CASE WHEN ("a_at" IS NULL) THEN 1 ELSE 0 END) AS "valid"`,
|
||||
},
|
||||
{
|
||||
fields: []string{"a", "b"},
|
||||
sql: `SELECT COUNT(*), SUM(CASE WHEN ("a_at" IS NOT NULL) THEN 1 ELSE 0 END) AS "a", SUM(CASE WHEN ("b_at" IS NOT NULL) THEN 1 ELSE 0 END) AS "b", SUM(CASE WHEN (("a_at" IS NULL) AND ("b_at" IS NULL)) THEN 1 ELSE 0 END) AS "valid"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.fields, "+"), func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ee = timestampStatExpr(tt.fields...)
|
||||
sql, _, err = goqu.Select(ee...).ToSQL()
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal(tt.sql, sql)
|
||||
})
|
||||
}
|
||||
}
|
||||
17
server/store/adapters/rdbms/custom_rbac.go
Normal file
17
server/store/adapters/rdbms/custom_rbac.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) TransferRbacRules(ctx context.Context, src, dst uint64) (err error) {
|
||||
var (
|
||||
transfer = s.Dialect.GOQU().Update(rbacRuleTable).
|
||||
Set(goqu.Record{"rel_role": dst}).
|
||||
Where(goqu.Ex{"rel_role": src})
|
||||
)
|
||||
|
||||
return s.Exec(ctx, transfer)
|
||||
}
|
||||
37
server/store/adapters/rdbms/custom_resource_translations.go
Normal file
37
server/store/adapters/rdbms/custom_resource_translations.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func (s Store) TransformResource(ctx context.Context, lang language.Tag) (out map[string]map[string]*locale.ResourceTranslation, err error) {
|
||||
out = make(map[string]map[string]*locale.ResourceTranslation)
|
||||
var cc types.ResourceTranslationSet
|
||||
var ok bool
|
||||
|
||||
cc, _, err = s.SearchResourceTranslations(ctx, types.ResourceTranslationFilter{
|
||||
Lang: lang.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range cc {
|
||||
if _, ok = out[c.Resource]; !ok {
|
||||
out[c.Resource] = make(map[string]*locale.ResourceTranslation)
|
||||
}
|
||||
|
||||
out[c.Resource][c.K] = &locale.ResourceTranslation{
|
||||
Resource: c.Resource,
|
||||
Lang: c.Lang.String(),
|
||||
Key: c.K,
|
||||
Msg: c.Message,
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
66
server/store/adapters/rdbms/custom_roles.go
Normal file
66
server/store/adapters/rdbms/custom_roles.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
systemType "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s Store) RoleMetrics(ctx context.Context) (m *systemType.RoleMetrics, err error) {
|
||||
var (
|
||||
aux = struct {
|
||||
Total uint `db:"total"`
|
||||
Deleted uint `db:"deleted"`
|
||||
Valid uint `db:"valid"`
|
||||
Archived uint `db:"archived"`
|
||||
}{}
|
||||
|
||||
query = roleSelectQuery(s.Dialect.GOQU()).
|
||||
Select(timestampStatExpr("deleted", "archived")...)
|
||||
)
|
||||
|
||||
if err = s.QueryOne(ctx, query, &aux); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m = &systemType.RoleMetrics{
|
||||
Total: aux.Total,
|
||||
Valid: aux.Valid,
|
||||
Deleted: aux.Deleted,
|
||||
Archived: aux.Archived,
|
||||
}
|
||||
|
||||
// Fetch daily metrics for created, updated, deleted and suspended users
|
||||
err = s.multiDailyMetrics(
|
||||
ctx,
|
||||
roleTable,
|
||||
[]goqu.Expression{},
|
||||
[]string{
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"archived_at",
|
||||
},
|
||||
&m.DailyCreated,
|
||||
&m.DailyUpdated,
|
||||
&m.DailyDeleted,
|
||||
&m.DailyArchived,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s Store) TransferRoleMembers(ctx context.Context, src, dst uint64) (err error) {
|
||||
var (
|
||||
transfer = s.Dialect.GOQU().Update(roleMemberTable).
|
||||
Set(goqu.Record{"rel_role": dst}).
|
||||
Where(goqu.Ex{"rel_role": src})
|
||||
)
|
||||
|
||||
return s.Exec(ctx, transfer)
|
||||
}
|
||||
85
server/store/adapters/rdbms/custom_users.go
Normal file
85
server/store/adapters/rdbms/custom_users.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
systemType "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
func (s *Store) CountUsers(ctx context.Context, f systemType.UserFilter) (c uint, _ error) {
|
||||
var (
|
||||
aux = struct {
|
||||
Count uint `db:"count"`
|
||||
}{}
|
||||
|
||||
expr, _, err = s.Filters.User(s, f)
|
||||
|
||||
query = s.Dialect.GOQU().
|
||||
From(userTable).
|
||||
Select(goqu.COUNT(goqu.Star()).As("count"))
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.QueryOne(ctx, query.Where(expr...).Limit(1), &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return aux.Count, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserMetrics(ctx context.Context) (m *systemType.UserMetrics, err error) {
|
||||
var (
|
||||
aux = struct {
|
||||
Total uint `db:"total"`
|
||||
Deleted uint `db:"deleted"`
|
||||
Valid uint `db:"valid"`
|
||||
Suspended uint `db:"suspended"`
|
||||
}{}
|
||||
|
||||
genericExcl = goqu.C("kind").Eq("")
|
||||
|
||||
query = userSelectQuery(s.Dialect.GOQU()).
|
||||
Where(genericExcl).
|
||||
Select(timestampStatExpr("deleted", "suspended")...)
|
||||
)
|
||||
|
||||
if err = s.QueryOne(ctx, query, &aux); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m = &systemType.UserMetrics{
|
||||
Total: aux.Total,
|
||||
Valid: aux.Valid,
|
||||
Deleted: aux.Deleted,
|
||||
Suspended: aux.Suspended,
|
||||
}
|
||||
|
||||
// Fetch daily metrics for created, updated, deleted and suspended users
|
||||
err = s.multiDailyMetrics(
|
||||
ctx,
|
||||
userTable,
|
||||
[]goqu.Expression{
|
||||
genericExcl,
|
||||
},
|
||||
[]string{
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"suspended_at",
|
||||
},
|
||||
&m.DailyCreated,
|
||||
&m.DailyUpdated,
|
||||
&m.DailyDeleted,
|
||||
&m.DailySuspended,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
559
server/store/adapters/rdbms/dal/connection.go
Normal file
559
server/store/adapters/rdbms/dal/connection.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// connection provides (pkg/dal.Connection) interface to RDBMS implementation
|
||||
//
|
||||
// In other words: this allows Corteza to read Records from the supported SQL databases
|
||||
connection struct {
|
||||
mux sync.RWMutex
|
||||
models map[string]*model
|
||||
driver dal.Driver
|
||||
|
||||
db sqlx.ExtContext
|
||||
dialect drivers.Dialect
|
||||
|
||||
dataDefiner ddl.DataDefiner
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
dalDriver dal.Driver
|
||||
)
|
||||
|
||||
func init() {
|
||||
dalDriver = dal.Driver{
|
||||
Type: "corteza::dal:driver:rdbms",
|
||||
Operations: dal.FullOperations(),
|
||||
Connection: dal.NewDSNDriverConnectionConfig(),
|
||||
}
|
||||
dal.RegisterDriver(dalDriver)
|
||||
}
|
||||
|
||||
func Connection(db sqlx.ExtContext, dialect drivers.Dialect, dd ddl.DataDefiner) *connection {
|
||||
return &connection{
|
||||
db: db,
|
||||
dialect: dialect,
|
||||
dataDefiner: dd,
|
||||
models: make(map[string]*model),
|
||||
driver: dalDriver,
|
||||
}
|
||||
}
|
||||
|
||||
// model returns rdbms/dal model (converted dal.Model)
|
||||
//
|
||||
// It constructs key from res-type + res + ident
|
||||
// and caches it in the connection
|
||||
//
|
||||
// This allows us to have same resource or ident on different res-types
|
||||
// For example: module's model for revisions has same resouce and ident but different type
|
||||
func (c *connection) withModel(m *dal.Model, fn func(m *model) error) error {
|
||||
var (
|
||||
key = cacheKey(m)
|
||||
)
|
||||
c.mux.RLock()
|
||||
defer c.mux.RUnlock()
|
||||
if cached, ok := c.models[key]; ok {
|
||||
return fn(cached)
|
||||
}
|
||||
|
||||
return fmt.Errorf("model %q (%d) not loaded", key, m.ResourceID)
|
||||
}
|
||||
|
||||
func (c *connection) Operations() dal.OperationSet {
|
||||
return c.driver.Operations
|
||||
}
|
||||
|
||||
func (c *connection) Can(operations ...dal.Operation) bool {
|
||||
return c.Operations().IsSuperset(operations...)
|
||||
}
|
||||
|
||||
func (c *connection) Create(ctx context.Context, m *dal.Model, rr ...dal.ValueGetter) (err error) {
|
||||
return c.withModel(m, func(m *model) error {
|
||||
return m.Create(ctx, rr...)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Update(ctx context.Context, m *dal.Model, r dal.ValueGetter) (err error) {
|
||||
return c.withModel(m, func(m *model) error {
|
||||
return m.Update(ctx, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Lookup(ctx context.Context, m *dal.Model, pkv dal.ValueGetter, r dal.ValueSetter) (err error) {
|
||||
return c.withModel(m, func(m *model) error {
|
||||
return m.Lookup(ctx, pkv, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Search(ctx context.Context, m *dal.Model, f filter.Filter) (i dal.Iterator, _ error) {
|
||||
return i, c.withModel(m, func(m *model) (err error) {
|
||||
i, err = m.Search(f)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Count(ctx context.Context, m *dal.Model, f filter.Filter) (i uint, _ error) {
|
||||
return i, c.withModel(m, func(m *model) (err error) {
|
||||
i, err = m.Count(ctx, f)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Analyze(ctx context.Context, m *dal.Model) (a map[string]dal.OpAnalysis, err error) {
|
||||
// @todo somehow (probably operations) bring in the info what can be done
|
||||
// for now, since we're quite rigid on the drivers, this will do.
|
||||
//
|
||||
// @note this is a temporary hack until we properly address the first point.
|
||||
// No point in complicating it at this stage.
|
||||
if c.db.DriverName() == "sqlserver" {
|
||||
a = map[string]dal.OpAnalysis{}
|
||||
} else {
|
||||
a = map[string]dal.OpAnalysis{
|
||||
dal.OpAnalysisAggregate: {
|
||||
ScanCost: dal.CostCheep,
|
||||
SearchCost: dal.CostCheep,
|
||||
FilterCost: dal.CostCheep,
|
||||
SortCost: dal.CostCheep,
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) Aggregate(ctx context.Context, m *dal.Model, f filter.Filter, groupBy []dal.AggregateAttr, aggrExpr []dal.AggregateAttr, having *ql.ASTNode) (i dal.Iterator, _ error) {
|
||||
return i, c.withModel(m, func(m *model) (err error) {
|
||||
i, err = m.Aggregate(f, groupBy, aggrExpr, having)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Delete(ctx context.Context, m *dal.Model, pkv dal.ValueGetter) (err error) {
|
||||
return c.withModel(m, func(m *model) error {
|
||||
return m.Delete(ctx, pkv)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Truncate(ctx context.Context, m *dal.Model) (err error) {
|
||||
return c.withModel(m, func(m *model) error {
|
||||
return m.Truncate(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *connection) Models(ctx context.Context) (dal.ModelSet, error) {
|
||||
// not raising not-supported error
|
||||
// because we do not want to break
|
||||
// DAL service model adding procedure
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreateModel checks/creates db tables in the database and catches the processed model
|
||||
//
|
||||
// @todo DDL operations
|
||||
func (c *connection) CreateModel(ctx context.Context, mm ...*dal.Model) (err error) {
|
||||
for _, m := range mm {
|
||||
if err = validate(m); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
for _, m := range mm {
|
||||
_, err = c.dataDefiner.TableLookup(ctx, m.Ident)
|
||||
if errors.IsNotFound(err) {
|
||||
if err = ddl.CreateModel(ctx, c.dataDefiner, m); err != nil {
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
return
|
||||
}
|
||||
if err = ddl.EnsureIndexes(ctx, c.dataDefiner, m.Indexes...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// cache the model
|
||||
c.models[cacheKey(m)] = Model(m, c.db, c.dialect)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteModel removes db tables from the database and removes the processed model from cache
|
||||
//
|
||||
// @todo DDL operations
|
||||
// @todo some tables should not be removed (like compose_record on primary connection)
|
||||
func (c *connection) DeleteModel(ctx context.Context, mm ...*dal.Model) (err error) {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
for _, m := range mm {
|
||||
// @todo check if table exists and if it can be removed
|
||||
if err = c.DeleteModel(ctx, m); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// remove from cache
|
||||
delete(c.models, cacheKey(m))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateModel alters db tables from the database and refreshes the processed model in the cache
|
||||
//
|
||||
// @todo DDL operations
|
||||
// @todo some tables should not be removed (like compose_record on primary connection)
|
||||
func (c *connection) UpdateModel(ctx context.Context, old *dal.Model, new *dal.Model) (err error) {
|
||||
if err = validate(new); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
// remove from cache
|
||||
delete(c.models, cacheKey(old))
|
||||
|
||||
// @todo check if column exists and if it can be removed
|
||||
|
||||
// update the cache
|
||||
c.models[cacheKey(new)] = Model(new, c.db, c.dialect)
|
||||
return
|
||||
}
|
||||
|
||||
// AssertSchemaAlterations returns a new set of Alterations based on what the underlying
|
||||
// schema already provides -- it discards alterations for column additions that already exist, etc.
|
||||
func (c *connection) AssertSchemaAlterations(ctx context.Context, model *dal.Model, aa ...*dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
var aux []*dal.Alteration
|
||||
|
||||
t, err := c.dataDefiner.TableLookup(ctx, model.Ident)
|
||||
if err != nil && errors.IsNotFound(err) {
|
||||
// Since there is no thing for this model we need to create it and all the
|
||||
// alterations are pointless
|
||||
return []*dal.Alteration{{
|
||||
ID: id.Next(),
|
||||
Resource: model.Resource,
|
||||
ResourceType: model.ResourceType,
|
||||
ConnectionID: model.ConnectionID,
|
||||
|
||||
ModelAdd: &dal.ModelAdd{
|
||||
Model: model,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Index columns by ident for easier lookup
|
||||
colIndex := make(map[string]*ddl.Column)
|
||||
for _, c := range t.Columns {
|
||||
colIndex[c.Ident] = c
|
||||
}
|
||||
|
||||
for _, a := range aa {
|
||||
switch {
|
||||
case a.AttributeAdd != nil:
|
||||
aux, err = c.assertAlterationAttributeAdd(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
case a.AttributeDelete != nil:
|
||||
aux, err = c.assertAlterationAttributeDelete(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
case a.AttributeReType != nil:
|
||||
aux, err = c.assertAlterationAttributeReType(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
case a.AttributeReEncode != nil:
|
||||
aux, err = c.assertAlterationAttributeReEncode(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
case a.ModelAdd != nil:
|
||||
aux, err = c.assertAlterationModelAdd(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
case a.ModelDelete != nil:
|
||||
aux, err = c.assertAlterationModelDelete(t, colIndex, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ApplyAhlteration applies the given alterations to the underlying schema
|
||||
//
|
||||
// The returned slice of error indicates what alterations failed.
|
||||
// If the corresponding index is nil, the alteration was successful.
|
||||
func (c *connection) ApplyAlteration(ctx context.Context, model *dal.Model, alt ...*dal.Alteration) (errs []error) {
|
||||
var (
|
||||
err error
|
||||
failed = make(map[uint64]bool, len(alt)/2)
|
||||
)
|
||||
|
||||
for _, a := range alt {
|
||||
// Skip since the alteration we depend on failed
|
||||
if a.DependsOn != 0 && failed[a.DependsOn] {
|
||||
errs = append(errs, fmt.Errorf("skipping alteration %d: depending alteration %d failed", a.ID, a.DependsOn))
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case a.AttributeAdd != nil:
|
||||
err = c.applyAlterationAttributeAdd(ctx, model, a)
|
||||
case a.AttributeDelete != nil:
|
||||
err = c.applyAlterationAttributeDelete(ctx, model, a)
|
||||
case a.AttributeReType != nil:
|
||||
err = c.applyAlterationAttributeReType(ctx, model, a)
|
||||
case a.AttributeReEncode != nil:
|
||||
err = c.applyAlterationAttributeReEncode(ctx, model, a)
|
||||
case a.ModelAdd != nil:
|
||||
err = c.applyAlterationModelAdd(ctx, model, a)
|
||||
case a.ModelDelete != nil:
|
||||
err = c.applyAlterationModelDelete(ctx, model, a)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
failed[a.ID] = true
|
||||
}
|
||||
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) applyAlterationAttributeAdd(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
col, err := c.dataDefiner.ConvertAttribute(alt.AttributeAdd.Attr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.dataDefiner.ColumnAdd(ctx, model.Ident, col)
|
||||
}
|
||||
|
||||
func (c *connection) applyAlterationAttributeDelete(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
return c.dataDefiner.ColumnDrop(ctx, model.Ident, alt.AttributeDelete.Attr.StoreIdent())
|
||||
}
|
||||
|
||||
func (c *connection) applyAlterationAttributeReType(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
col, err := c.dataDefiner.ConvertAttribute(alt.AttributeReType.Attr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.dataDefiner.ColumnReType(ctx, model.Ident, col.Ident, col.Type)
|
||||
}
|
||||
|
||||
// @todo might consider droppig this one for now
|
||||
func (c *connection) applyAlterationAttributeReEncode(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
// ...
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) applyAlterationModelAdd(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
t, err := c.dataDefiner.ConvertModel(model)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.dataDefiner.TableCreate(ctx, t)
|
||||
}
|
||||
|
||||
func (c *connection) applyAlterationModelDelete(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
|
||||
return c.dataDefiner.TableDrop(ctx, model.Ident)
|
||||
}
|
||||
|
||||
func cacheKey(m *dal.Model) (key string) {
|
||||
key = m.ResourceType + "|" + m.Resource + "|" + m.Ident
|
||||
if key == "" {
|
||||
panic("can not add model without a key (combo of resource type, resource and ident)")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
if alt.AttributeAdd.Attr.Store.Type() == dal.AttributeCodecRecordValueSetJSON {
|
||||
// RecordValue codec needs to be checked a bit differently since we're worried about the column that contains
|
||||
// the JSON, not the attribute ident itself
|
||||
return c.assertAlterationNestedAttributeAdd(table, colIndex, alt)
|
||||
} else {
|
||||
return c.assertAlterationStandaloneAttributeAdd(table, colIndex, alt)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationNestedAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
col := colIndex[alt.AttributeAdd.Attr.StoreIdent()]
|
||||
if col != nil {
|
||||
return
|
||||
}
|
||||
|
||||
a := &dal.Alteration{
|
||||
ID: id.Next(),
|
||||
BatchID: alt.BatchID,
|
||||
DependsOn: alt.DependsOn,
|
||||
Resource: alt.Resource,
|
||||
ConnectionID: alt.ConnectionID,
|
||||
|
||||
AttributeAdd: &dal.AttributeAdd{
|
||||
Attr: &dal.Attribute{
|
||||
Ident: alt.AttributeAdd.Attr.StoreIdent(),
|
||||
Store: &dal.CodecPlain{},
|
||||
Type: &dal.TypeJSON{Nullable: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
// Update colIndex so other alterations won't duplicate
|
||||
auxCol, err := c.dialect.AttributeToColumn(a.AttributeAdd.Attr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
colIndex[auxCol.Ident] = auxCol
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationStandaloneAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
col := colIndex[alt.AttributeAdd.Attr.StoreIdent()]
|
||||
if col == nil {
|
||||
out = append(out, alt)
|
||||
return
|
||||
}
|
||||
|
||||
auxCol, err := c.dialect.AttributeToColumn(alt.AttributeAdd.Attr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !c.dialect.ColumnFits(col, auxCol) {
|
||||
out = append(out, &dal.Alteration{
|
||||
ID: id.Next(),
|
||||
BatchID: alt.BatchID,
|
||||
DependsOn: alt.DependsOn,
|
||||
Resource: alt.Resource,
|
||||
ResourceType: alt.ResourceType,
|
||||
ConnectionID: alt.ConnectionID,
|
||||
|
||||
AttributeReType: &dal.AttributeReType{
|
||||
Attr: alt.AttributeAdd.Attr,
|
||||
To: alt.AttributeAdd.Attr.Type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update colIndex so other alterations won't duplicate
|
||||
colIndex[auxCol.Ident] = auxCol
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationAttributeDelete(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
col := colIndex[alt.AttributeDelete.Attr.StoreIdent()]
|
||||
if col == nil {
|
||||
return
|
||||
}
|
||||
|
||||
delete(colIndex, alt.AttributeDelete.Attr.StoreIdent())
|
||||
|
||||
out = append(out, alt)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationAttributeReType(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
col := colIndex[alt.AttributeReType.Attr.StoreIdent()]
|
||||
if col == nil {
|
||||
err = fmt.Errorf("cannot alter %s, column does not exist", alt.AttributeReType.Attr.StoreIdent())
|
||||
return
|
||||
}
|
||||
|
||||
// Since it's a JSON we don't need to do anything
|
||||
// @todo consider adding some migration logic here
|
||||
if alt.AttributeReType.Attr.Store.Type() == dal.AttributeCodecRecordValueSetJSON {
|
||||
return
|
||||
}
|
||||
|
||||
auxCol, err := c.dialect.AttributeToColumn(alt.AttributeReType.Attr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.dialect.ColumnFits(auxCol, col) {
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, alt)
|
||||
return
|
||||
}
|
||||
|
||||
// @todo for now it just creates a new column; we should add some migration in the future
|
||||
func (c *connection) assertAlterationAttributeReEncode(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
auxCodec := *alt.AttributeReEncode.Attr
|
||||
auxCodec.Store = alt.AttributeReEncode.To
|
||||
|
||||
out = append(out, &dal.Alteration{
|
||||
ID: alt.ID,
|
||||
BatchID: alt.BatchID,
|
||||
DependsOn: alt.DependsOn,
|
||||
Resource: alt.Resource,
|
||||
ResourceType: alt.ResourceType,
|
||||
ConnectionID: alt.ConnectionID,
|
||||
|
||||
AttributeAdd: &dal.AttributeAdd{
|
||||
Attr: &auxCodec,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationModelAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
if table != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, alt)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connection) assertAlterationModelDelete(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
|
||||
if table == nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, alt)
|
||||
return
|
||||
}
|
||||
323
server/store/adapters/rdbms/dal/iterator.go
Normal file
323
server/store/adapters/rdbms/dal/iterator.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
iterator struct {
|
||||
// source model; how data we are reading from is shaped
|
||||
src *model
|
||||
|
||||
// destination model; how data we are reading into is shaped
|
||||
// this is used to create scan buffer
|
||||
// when not doing plain selection from one table final results might
|
||||
// require a different list of columns and scanning needs to be adjusted
|
||||
dst *model
|
||||
|
||||
// buffer for scanned rows
|
||||
scanBuf []any
|
||||
|
||||
// results from the last read
|
||||
rows *sql.Rows
|
||||
|
||||
// last error
|
||||
err error
|
||||
|
||||
query *goqu.SelectDataset
|
||||
sorting filter.SortExprSet
|
||||
cursor *filter.PagingCursor
|
||||
limit uint
|
||||
|
||||
// set this flag when iterating over aggregated results
|
||||
// it will affect how
|
||||
// - cursor conditions are applied (WHERE )
|
||||
aggregated bool
|
||||
}
|
||||
)
|
||||
|
||||
func (i *iterator) Next(ctx context.Context) bool {
|
||||
if i.err == nil && i.rows == nil {
|
||||
i.rows, i.err = i.fetch(ctx)
|
||||
}
|
||||
|
||||
if i.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return i.rows.Next()
|
||||
}
|
||||
|
||||
// More fetches more records from the point of last record
|
||||
func (i *iterator) More(max uint, last dal.ValueGetter) (err error) {
|
||||
if i.rows != nil {
|
||||
if err = i.rows.Close(); err != nil {
|
||||
return fmt.Errorf("could not close previous query: %w", err)
|
||||
}
|
||||
i.rows = nil
|
||||
}
|
||||
|
||||
i.limit = max
|
||||
if last != nil {
|
||||
if i.cursor == nil || !i.cursor.ROrder {
|
||||
i.cursor, err = i.ForwardCursor(last)
|
||||
} else {
|
||||
i.cursor, err = i.BackCursor(last)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not collect cursor values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *iterator) Preload(_ context.Context, max uint, cur *filter.PagingCursor) (err error) {
|
||||
if i.rows != nil {
|
||||
if err = i.rows.Close(); err != nil {
|
||||
return fmt.Errorf("could not close previous query: %w", err)
|
||||
}
|
||||
i.rows = nil
|
||||
}
|
||||
|
||||
i.limit = max
|
||||
if cur != nil {
|
||||
i.cursor = cur
|
||||
}
|
||||
|
||||
return i.err
|
||||
}
|
||||
|
||||
func (i *iterator) Sorting() filter.SortExprSet {
|
||||
return i.sorting
|
||||
}
|
||||
|
||||
func (i *iterator) fetch(ctx context.Context) (rows *sql.Rows, err error) {
|
||||
if i.err != nil {
|
||||
return nil, i.err
|
||||
}
|
||||
|
||||
if i.query == nil {
|
||||
return nil, fmt.Errorf("can not fetch without query")
|
||||
}
|
||||
|
||||
if i.scanBuf == nil {
|
||||
// we're going to init scan buffer only once
|
||||
// and rely on the query.Rows.Scan function to
|
||||
// fill it up with fresh values!
|
||||
i.scanBuf = i.dst.table.MakeScanBuffer()
|
||||
}
|
||||
|
||||
var (
|
||||
cur = i.cursor
|
||||
|
||||
tmp exp.Expression
|
||||
query string
|
||||
args []any
|
||||
|
||||
// contains query with ORDER BY and WHERE clauses
|
||||
sqlExpr = i.query
|
||||
|
||||
sort = i.sorting.Clone()
|
||||
)
|
||||
|
||||
{
|
||||
// Apply limit from the filter
|
||||
sqlExpr = sqlExpr.Limit(i.limit)
|
||||
|
||||
if cur != nil {
|
||||
// @todo this needs to work with embedded attributes (non physical columns) as well!
|
||||
tmp, err = rdbms.CursorExpression(
|
||||
cur,
|
||||
func(ident string) (exp.Expression, error) { return i.src.table.AttributeExpression(ident) },
|
||||
func(ident string, val any) (exp.Expression, error) {
|
||||
attr := i.dst.model.Attributes.FindByIdent(ident)
|
||||
if attr == nil {
|
||||
panic("unknown attribute " + ident + " used in cursor expression cast callback")
|
||||
}
|
||||
|
||||
enc, err := i.dst.dialect.TypeWrap(attr.Type).Encode(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", enc), nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if i.aggregated {
|
||||
sqlExpr = sqlExpr.Having(tmp)
|
||||
} else {
|
||||
sqlExpr = sqlExpr.Where(tmp)
|
||||
}
|
||||
|
||||
if cur.IsROrder() {
|
||||
if i.limit > 0 {
|
||||
// When paging with the reverse cursor AND limit set
|
||||
// we need to do a do sub-query reverse sort to ensure
|
||||
// that we only select the rows that make sense
|
||||
|
||||
innerSort := sort.Clone()
|
||||
innerSort.Reverse()
|
||||
|
||||
// Wrap the fil & ordered sub-query with cursor-conditions
|
||||
sqlExpr = i.src.dialect.GOQU().From(sqlExpr.Order(i.orderByExp(innerSort)...).As(i.src.model.Ident))
|
||||
|
||||
// make sure we don't reverse it again
|
||||
} else {
|
||||
// if limit is not set it does not make sense to wrap the select
|
||||
// and resort it, so let's reverse the main sorting
|
||||
sort.Reverse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Apply sort
|
||||
|
||||
// @todo is this going to be a problem? do we need to properly address the columns
|
||||
// from the sub-query?
|
||||
if len(sort) > 0 {
|
||||
sqlExpr = sqlExpr.Order(i.orderByExp(sort)...)
|
||||
}
|
||||
}
|
||||
|
||||
if query, args, err = sqlExpr.ToSQL(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = i.src.conn.QueryContext(ctx, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// no rows, no error
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// generates slice of ordered-expressions
|
||||
func (i *iterator) orderByExp(sort filter.SortExprSet) (oe []exp.OrderedExpression) {
|
||||
for _, s := range sort {
|
||||
// assuming all columns were pre-validated!
|
||||
tmp, _ := i.src.table.AttributeExpression(s.Column)
|
||||
|
||||
if s.Descending {
|
||||
oe = append(oe, i.src.dialect.OrderedExpression(tmp, exp.DescSortDir, exp.NullsLastSortType))
|
||||
} else {
|
||||
oe = append(oe, i.src.dialect.OrderedExpression(tmp, exp.AscDir, exp.NullsFirstSortType))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *iterator) Scan(r dal.ValueSetter) (err error) {
|
||||
if i.err != nil {
|
||||
return i.err
|
||||
}
|
||||
|
||||
if err = i.rows.Scan(i.scanBuf...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = i.dst.table.Decode(i.scanBuf, r); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *iterator) Err() error {
|
||||
return i.err
|
||||
}
|
||||
|
||||
// Close iterator and cleanup
|
||||
func (i *iterator) Close() error {
|
||||
if i.rows == nil {
|
||||
return nil
|
||||
}
|
||||
return i.rows.Close()
|
||||
}
|
||||
|
||||
func (i *iterator) BackCursor(r dal.ValueGetter) (cur *filter.PagingCursor, err error) {
|
||||
cur, err = i.collectCursorValues(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// if this cursor is used, we need to reverse the sorting order
|
||||
cur.ROrder = true
|
||||
|
||||
// if this cursor is used, we need to flip the conditional operator
|
||||
// from less-then to greater-then
|
||||
cur.LThen = i.sorting.Reversed()
|
||||
return
|
||||
}
|
||||
|
||||
func (i *iterator) ForwardCursor(r dal.ValueGetter) (*filter.PagingCursor, error) {
|
||||
return i.collectCursorValues(r)
|
||||
}
|
||||
|
||||
func (i *iterator) collectCursorValues(r dal.ValueGetter) (_ *filter.PagingCursor, err error) {
|
||||
var (
|
||||
cur = &filter.PagingCursor{LThen: i.sorting.Reversed()}
|
||||
|
||||
// @todo this will not work when using multiple primary keys!
|
||||
pkUsed bool
|
||||
value any
|
||||
|
||||
pKeys = make(map[string]bool)
|
||||
)
|
||||
|
||||
for _, c := range i.dst.table.Columns() {
|
||||
if c.IsPrimaryKey() {
|
||||
attrIdent := c.Attribute().Ident
|
||||
pKeys[attrIdent] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(pKeys) == 0 {
|
||||
//return nil, fmt.Errorf("can not construct cursor without primary key attributes")
|
||||
}
|
||||
|
||||
for _, c := range i.sorting {
|
||||
if pKeys[c.Column] {
|
||||
pkUsed = true
|
||||
}
|
||||
|
||||
if value, err = r.GetValue(c.Column, 0); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cur.Set(c.Column, value, c.Descending)
|
||||
}
|
||||
|
||||
if !pkUsed {
|
||||
for key := range pKeys {
|
||||
value, err = r.GetValue(key, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cur.Set(key, value, false)
|
||||
}
|
||||
}
|
||||
|
||||
return cur, nil
|
||||
}
|
||||
170
server/store/adapters/rdbms/dal/main_test.go
Normal file
170
server/store/adapters/rdbms/dal/main_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package dal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/mysql"
|
||||
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/postgres"
|
||||
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
)
|
||||
|
||||
type (
|
||||
// @todo refactor tests that use kv and migrate to kvv
|
||||
// no need to keep and maintain both.
|
||||
kv map[string]any
|
||||
kvv map[string][]any
|
||||
)
|
||||
|
||||
var (
|
||||
s *rdbms.Store
|
||||
)
|
||||
|
||||
// @todo refactor tests to follow rdbms/tests, using connectionInfo...
|
||||
// @todo should be part of the general DAL testing suite
|
||||
// @todo new ENV var for enabling(def)/disabling temp. model (table) creation (DB_PERSIST_MODELS=true)
|
||||
func TestMain(m *testing.M) {
|
||||
var (
|
||||
dsn = os.Getenv("DB_DSN")
|
||||
log = logger.MakeDebugLogger()
|
||||
ctx = context.Background()
|
||||
err error
|
||||
aux store.Storer
|
||||
)
|
||||
|
||||
if len(dsn) == 0 {
|
||||
dsn = "sqlite3+debug://file::memory:?cache=shared&mode=memory"
|
||||
}
|
||||
|
||||
// ctx = logger.ContextWithValue(context.Background(), log)
|
||||
aux, err = store.Connect(ctx, log, dsn, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stdout, "could not connect: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s = aux.(*rdbms.Store)
|
||||
|
||||
m.Run()
|
||||
}
|
||||
|
||||
// truncates table
|
||||
func truncate(ctx context.Context, table string) error {
|
||||
table = s.Dialect.QuoteIdent(table)
|
||||
_, err := s.DB.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r kv) CountValues() map[string]uint {
|
||||
out := make(map[string]uint)
|
||||
|
||||
for k := range r {
|
||||
out[k]++
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (r kv) GetValue(k string, place uint) (any, error) {
|
||||
return r[k], nil
|
||||
}
|
||||
|
||||
func (r kv) SetValue(k string, place uint, v any) error {
|
||||
r[k] = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// String function returns string representation of the kv with sorted keys
|
||||
func (r kv) String() string {
|
||||
// sort keys from map
|
||||
keys := make([]string, 0, len(r))
|
||||
for k := range r {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
// build string by iterating over sorted keys and appending values
|
||||
var out string
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
out += " "
|
||||
}
|
||||
|
||||
out += fmt.Sprintf("%s=%v", k, r[k])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (r kvv) Set(k string, v ...any) kvv {
|
||||
r[k] = v
|
||||
return r
|
||||
}
|
||||
|
||||
func (r kvv) CountValues() map[string]uint {
|
||||
out := make(map[string]uint)
|
||||
|
||||
for k := range r {
|
||||
out[k] = uint(len(r[k]))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (r kvv) GetValue(k string, p uint) (any, error) {
|
||||
if r[k] == nil || len(r[k]) <= int(p) {
|
||||
return nil, fmt.Errorf("kvv: out of bounds")
|
||||
}
|
||||
|
||||
return r[k][p], nil
|
||||
}
|
||||
|
||||
func (r kvv) SetValue(k string, p uint, v any) error {
|
||||
if r[k] == nil {
|
||||
r[k] = make([]any, 0, 1)
|
||||
} else if len(r[k]) < int(p) {
|
||||
r[k][p] = v
|
||||
} else {
|
||||
r[k] = append(r[k], v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String function returns string representation of the kv with sorted keys
|
||||
func (r kvv) String() string {
|
||||
// sort keys from map
|
||||
keys := make([]string, 0, len(r))
|
||||
for k := range r {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
// build string by iterating over sorted keys and appending values
|
||||
var out string
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
out += " "
|
||||
}
|
||||
|
||||
out += fmt.Sprintf("%s=%v", k, r[k])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func qlParse(req *require.Assertions, q string) *ql.ASTNode {
|
||||
n, err := ql.NewParser().Parse(q)
|
||||
req.NoError(err)
|
||||
return n
|
||||
}
|
||||
805
server/store/adapters/rdbms/dal/model.go
Normal file
805
server/store/adapters/rdbms/dal/model.go
Normal file
@@ -0,0 +1,805 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/modern-go/reflect2"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
queryRunner interface {
|
||||
sqlx.QueryerContext
|
||||
sqlx.ExecerContext
|
||||
}
|
||||
|
||||
model struct {
|
||||
model *dal.Model
|
||||
conn queryRunner
|
||||
|
||||
dialect drivers.Dialect
|
||||
|
||||
table drivers.TableCodec
|
||||
}
|
||||
|
||||
parsedFilter interface {
|
||||
ExpressionParsed() *ql.ASTNode
|
||||
}
|
||||
|
||||
queryParser interface {
|
||||
Convert(*ql.ASTNode) (out exp.Expression, err error)
|
||||
Parse(string) (out exp.Expression, err error)
|
||||
}
|
||||
)
|
||||
|
||||
func validate(m *dal.Model) error {
|
||||
var (
|
||||
c2c = make(map[string]*dal.Attribute, len(m.Attributes))
|
||||
)
|
||||
|
||||
for _, a := range m.Attributes {
|
||||
if a.StoreIdent() == "" {
|
||||
return fmt.Errorf("attribute %q has no ident", a.Ident)
|
||||
}
|
||||
|
||||
if a.Store == nil {
|
||||
return fmt.Errorf("attribute %q has no store codec", a.Ident)
|
||||
}
|
||||
|
||||
usedBy, has := c2c[a.StoreIdent()]
|
||||
if has {
|
||||
// column already in the map
|
||||
if a.Store.SingleValueOnly() {
|
||||
return fmt.Errorf("attribute %q has single value codec but column %q is already used by attribute %q", a.Ident, a.StoreIdent(), usedBy.Ident)
|
||||
}
|
||||
}
|
||||
|
||||
c2c[a.StoreIdent()] = a
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Model returns fully initialized model store
|
||||
//
|
||||
// It abstracts database table and its columns and provides unified interface
|
||||
// for fetching and storing records.
|
||||
func Model(m *dal.Model, c queryRunner, d drivers.Dialect) *model {
|
||||
var (
|
||||
ms = &model{
|
||||
model: m,
|
||||
conn: c,
|
||||
dialect: d,
|
||||
table: drivers.NewTableCodec(m, d),
|
||||
}
|
||||
)
|
||||
|
||||
return ms
|
||||
}
|
||||
|
||||
// parseQuery parses the query into the goqu expression
|
||||
//
|
||||
// The parse query initializes a fresh converter instance because QL converter
|
||||
// uses some internal state to keep track of symbols and stuff.
|
||||
//
|
||||
// When doing parallel requests over the same model, unexpected... stuff happens.
|
||||
//
|
||||
// Alternative solution would introduce a mutes on the internal model but that
|
||||
// is probably the same or worse as this.
|
||||
func (d *model) parseQuery(q string) (out exp.Expression, err error) {
|
||||
return d.QueryParser().Parse(q)
|
||||
}
|
||||
|
||||
func (d *model) convertQuery(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
return d.QueryParser().Convert(n)
|
||||
}
|
||||
|
||||
// QueryParser returns ql struct that allows parsing query strings or converting AST into expression
|
||||
// @todo benchmark to see if this re-init is a bad idea; I don't think it should be
|
||||
//
|
||||
// since we're just initializing fairly light structs.
|
||||
func (d *model) QueryParser() queryParser {
|
||||
return ql.Converter(
|
||||
ql.SymHandler(d.qlConverterGenericSymHandler()),
|
||||
ql.ValHandler(d.qlConverterGenericValHandler()),
|
||||
ql.RefHandler(d.qlConverterGenericRefHandler()),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *model) qlConverterGenericSymHandler() func(node *ql.ASTNode) (exp.Expression, error) {
|
||||
return func(node *ql.ASTNode) (exp.Expression, error) {
|
||||
// @note normalize system idents on the RDBMS level for filters
|
||||
// offloaded to the database.
|
||||
sym := dal.NormalizeAttrNames(node.Symbol)
|
||||
if d.model.ResourceType == "corteza::compose:module" {
|
||||
// temporary solution
|
||||
//
|
||||
// before DAL some fields were aliased (recordID => ID)
|
||||
switch sym {
|
||||
case "recordID":
|
||||
sym = "ID"
|
||||
}
|
||||
}
|
||||
|
||||
if node.Meta == nil {
|
||||
node.Meta = make(map[string]any)
|
||||
}
|
||||
|
||||
attr := d.model.Attributes.FindByIdent(sym)
|
||||
if attr == nil {
|
||||
return nil, fmt.Errorf("unknown attribute %q used in query expression", node.Symbol)
|
||||
}
|
||||
|
||||
node.Meta["dal.Attribute"] = attr
|
||||
node.Meta["dal.Model"] = d.model
|
||||
|
||||
if !attr.Filterable {
|
||||
return nil, fmt.Errorf("attribute %q can not be used in query expression", attr.Ident)
|
||||
}
|
||||
|
||||
return d.table.AttributeExpression(sym)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *model) qlConverterGenericValHandler() func(*ql.ASTNode) (exp.Expression, error) {
|
||||
return func(node *ql.ASTNode) (exp.Expression, error) {
|
||||
return d.dialect.ValHandler(node)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *model) qlConverterGenericRefHandler() func(*ql.ASTNode, ...exp.Expression) (exp.Expression, error) {
|
||||
return func(node *ql.ASTNode, args ...exp.Expression) (exp.Expression, error) {
|
||||
return d.dialect.ExprHandler(node, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *model) Truncate(ctx context.Context) error {
|
||||
sql, args, err := d.truncateSql().ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.conn.ExecContext(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *model) Create(ctx context.Context, rr ...dal.ValueGetter) error {
|
||||
sql, args, err := d.insertSql(rr...).ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.conn.ExecContext(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *model) Update(ctx context.Context, r dal.ValueGetter) error {
|
||||
sql, args, err := d.updateSql(r).ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.conn.ExecContext(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *model) Delete(ctx context.Context, r dal.ValueGetter) error {
|
||||
sql, args, err := d.deleteSql(r).ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.conn.ExecContext(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *model) Search(f filter.Filter) (i *iterator, err error) {
|
||||
var (
|
||||
orderBy = f.OrderBy()
|
||||
)
|
||||
|
||||
if f.Cursor() != nil {
|
||||
// Page cursor exists; we need to validate it against used sort
|
||||
// To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions
|
||||
// from the cursor.
|
||||
// This (extracted sorting info) is then returned as part of response
|
||||
if orderBy, err = f.Cursor().Sort(orderBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// validate order-by
|
||||
for _, s := range orderBy {
|
||||
if _, err = d.table.AttributeExpression(s.Column); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if att := d.model.Attributes.FindByIdent(s.Column); att != nil && att.MultiValue {
|
||||
return nil, fmt.Errorf("not allowed to sort by multi-value attribute: %s", s.Column)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// sanitize filter a bit
|
||||
for _, c := range d.table.Columns() {
|
||||
if !c.IsPrimaryKey() {
|
||||
continue
|
||||
}
|
||||
|
||||
attrIdent := c.Attribute().Ident
|
||||
if orderBy.Get(attrIdent) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !c.Attribute().PrimaryKey && !c.Attribute().Sortable {
|
||||
return nil, fmt.Errorf("can not sort by %q; not sortable, not primary key", attrIdent)
|
||||
}
|
||||
|
||||
// Make sure results are always sorted at least by primary key
|
||||
orderBy = append(orderBy, &filter.SortExpr{Column: attrIdent, Descending: orderBy.LastDescending()})
|
||||
}
|
||||
|
||||
i = &iterator{
|
||||
// source and destination is the same
|
||||
src: d,
|
||||
dst: d,
|
||||
|
||||
sorting: orderBy,
|
||||
cursor: f.Cursor(),
|
||||
limit: f.Limit(),
|
||||
}
|
||||
|
||||
i.query = d.applyFiltersToQuery(d.selectSql(), f)
|
||||
if err = i.query.Error(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *model) Count(ctx context.Context, f filter.Filter) (c uint, err error) {
|
||||
var (
|
||||
aux = struct {
|
||||
Count uint `db:"count"`
|
||||
}{}
|
||||
|
||||
rows *sql.Rows
|
||||
exprs []exp.Expression
|
||||
|
||||
q = d.dialect.GOQU().
|
||||
From(d.table.Ident()).Select(goqu.COUNT(goqu.Star()).As("count"))
|
||||
)
|
||||
|
||||
for ident, val := range f.Constraints() {
|
||||
attrExpr, err := d.table.AttributeExpression(ident)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
exprs = append(exprs, exp.NewBooleanExpression(exp.EqOp, attrExpr, val))
|
||||
}
|
||||
|
||||
query, args, err := q.Where(exprs...).Limit(1).ToSQL()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err = d.conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, errors.NotFound("not found")
|
||||
}
|
||||
|
||||
if err = rows.Scan(&aux.Count); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c = aux.Count
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Aggregate constructs SELECT sql with group-by and an optional having CLAUSE
|
||||
//
|
||||
// All group-by attributes are prepended to aggregation
|
||||
// expressions when constructing expressions & columns to select from.
|
||||
//
|
||||
// Passing in filter with cursor, empty groupBy or aggrExpr slice will result in an error
|
||||
func (d *model) Aggregate(f filter.Filter, groupBy []dal.AggregateAttr, aggrExpr []dal.AggregateAttr, having *ql.ASTNode) (i *iterator, err error) {
|
||||
if len(groupBy) == 0 {
|
||||
return nil, fmt.Errorf("can not run aggregation without group-by")
|
||||
}
|
||||
|
||||
i = &iterator{
|
||||
cursor: f.Cursor(),
|
||||
sorting: f.OrderBy(),
|
||||
limit: f.Limit(),
|
||||
}
|
||||
|
||||
var (
|
||||
// source model; how data we are reading from is shaped
|
||||
srcModel = &dal.Model{}
|
||||
|
||||
// destination model; how data we are reading into is shaped
|
||||
dstModel = &dal.Model{}
|
||||
|
||||
srcAttr, dstAttr *dal.Attribute
|
||||
)
|
||||
|
||||
// prepare a bit modified module that
|
||||
// describes aggregated columns (prepending attributes used for group-by)
|
||||
for _, c := range append(groupBy, aggrExpr...) {
|
||||
srcAttr = &dal.Attribute{
|
||||
Ident: c.Identifier,
|
||||
Type: c.Type,
|
||||
Store: c.Store,
|
||||
}
|
||||
srcModel.Attributes = append(srcModel.Attributes, srcAttr)
|
||||
|
||||
dstAttr = &dal.Attribute{
|
||||
Ident: c.Identifier,
|
||||
Type: c.Type,
|
||||
}
|
||||
dstModel.Attributes = append(dstModel.Attributes, dstAttr)
|
||||
}
|
||||
|
||||
i.src = Model(srcModel, d.conn, d.dialect)
|
||||
i.dst = Model(dstModel, d.conn, d.dialect)
|
||||
|
||||
i.query = d.aggregateSql(f, groupBy, aggrExpr, having)
|
||||
|
||||
if err = i.query.Error(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *model) Lookup(ctx context.Context, pkv dal.ValueGetter, r dal.ValueSetter) (err error) {
|
||||
query, args, err := d.lookupSql(pkv).ToSQL()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// using sql.Rows instead of a row
|
||||
// this gives us more control over closing (the rows resource)
|
||||
// and ability to use sql.RawBytes
|
||||
var rows *sql.Rows
|
||||
rows, err = d.conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return errors.NotFound("not found")
|
||||
}
|
||||
|
||||
scanBuf := d.table.MakeScanBuffer()
|
||||
if err = rows.Scan(scanBuf...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = d.table.Decode(scanBuf, r); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
// converting parts of record filter into conditions for selecting records from a table
|
||||
//
|
||||
// Does not add any limits, sorting or any cursor conditions!
|
||||
func (d *model) applyFiltersToQuery(base *goqu.SelectDataset, f filter.Filter) *goqu.SelectDataset {
|
||||
var (
|
||||
err error
|
||||
tmp exp.Expression
|
||||
cnd []exp.Expression
|
||||
)
|
||||
|
||||
{
|
||||
// Add model & namespace constraints when model expects (has configured attributes) for them
|
||||
//
|
||||
// This covers both scenarios:
|
||||
// 1) Model is configured to store records in a dedicated table
|
||||
// without model and/or namespace attributes
|
||||
//
|
||||
// 2) Model has model and/or namespace attribute and saves records
|
||||
// from different modules in the same table
|
||||
|
||||
// if d.sysExprNamespaceID != nil {
|
||||
// cnd = append(cnd, d.sysExprNamespaceID.Eq(f.NamespaceID))
|
||||
// } else {
|
||||
// // @todo check if f.NamespaceID is compatible
|
||||
// }
|
||||
//
|
||||
// if d.sysExprModuleID != nil {
|
||||
// cnd = append(cnd, d.sysExprModuleID.Eq(f.ModuleID))
|
||||
// } else {
|
||||
// // @todo check if f.ModuleID is compatible
|
||||
// }
|
||||
}
|
||||
|
||||
cc := f.Constraints()
|
||||
if d.model.Constraints != nil {
|
||||
if cc == nil {
|
||||
cc = d.model.Constraints
|
||||
} else {
|
||||
for k, c := range d.model.Constraints {
|
||||
// Overwrite user-provided constraints as the system ones are more important
|
||||
cc[k] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ident, vv := range cc {
|
||||
attr := d.model.Attributes.FindByIdent(ident)
|
||||
if attr == nil {
|
||||
return base.SetError(fmt.Errorf("unknown attribute %q used for constrant", ident))
|
||||
}
|
||||
|
||||
// @note why?
|
||||
// if !attr.PrimaryKey {
|
||||
// continue
|
||||
// }
|
||||
|
||||
var attrExpr exp.Expression
|
||||
attrExpr, err = d.table.AttributeExpression(attr.Ident)
|
||||
if err != nil {
|
||||
return base.SetError(err)
|
||||
}
|
||||
|
||||
if len(vv) > 0 {
|
||||
cnd = append(cnd, exp.NewBooleanExpression(exp.InOp, attrExpr, vv))
|
||||
}
|
||||
}
|
||||
|
||||
for ident, state := range f.StateConstraints() {
|
||||
attr := d.model.Attributes.FindByIdent(ident)
|
||||
if attr == nil {
|
||||
if ident == "deletedAt" {
|
||||
// workaround for situation where filter constraints
|
||||
// contain deletedAt but attribute does not exist
|
||||
continue
|
||||
}
|
||||
|
||||
return base.SetError(fmt.Errorf("unknown attribute %q used for state constrant", ident))
|
||||
}
|
||||
|
||||
if !attr.Type.IsNullable() {
|
||||
// @todo this must be checked much earlier
|
||||
return base.SetError(fmt.Errorf("can not use non-nullable attribute %q soft-deleting", attr.Ident))
|
||||
}
|
||||
|
||||
if state == filter.StateInclusive {
|
||||
// not used so we don't rea
|
||||
continue
|
||||
}
|
||||
|
||||
var attrExpr exp.Expression
|
||||
attrExpr, err = d.table.AttributeExpression(attr.Ident)
|
||||
if err != nil {
|
||||
return base.SetError(err)
|
||||
}
|
||||
|
||||
switch state {
|
||||
case filter.StateExclusive:
|
||||
// only not-null values
|
||||
cnd = append(cnd, exp.NewLiteralExpression("? IS NOT NULL", attrExpr))
|
||||
|
||||
case filter.StateExcluded:
|
||||
// exclude all non-null values
|
||||
cnd = append(cnd, exp.NewLiteralExpression("? IS NULL", attrExpr))
|
||||
}
|
||||
}
|
||||
|
||||
if mc := f.MetaConstraints(); len(mc) > 0 {
|
||||
attr := d.model.Attributes.FindByIdent("meta")
|
||||
if attr == nil {
|
||||
return base.SetError(fmt.Errorf("can not filter records in this module by meta: no meta attribute defined"))
|
||||
}
|
||||
|
||||
var (
|
||||
metaKeyExpr exp.Expression
|
||||
metaAttrIdent = exp.NewIdentifierExpression("", d.model.Ident, attr.Ident)
|
||||
)
|
||||
|
||||
for mKey, mVal := range f.MetaConstraints() {
|
||||
metaKeyExpr, err = d.dialect.JsonExtractUnquote(metaAttrIdent, mKey)
|
||||
if err != nil {
|
||||
return base.SetError(err)
|
||||
}
|
||||
|
||||
if reflect2.IsNil(mVal) {
|
||||
cnd = append(cnd, exp.NewBooleanExpression(exp.IsNotOp, metaKeyExpr, nil))
|
||||
} else {
|
||||
cnd = append(cnd, exp.NewBooleanExpression(exp.EqOp, metaKeyExpr, mVal))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pf, ok := f.(parsedFilter); ok {
|
||||
tmp := pf.ExpressionParsed()
|
||||
if tmp != nil {
|
||||
n, err := d.convertQuery(tmp)
|
||||
if err != nil {
|
||||
return base.SetError(err)
|
||||
}
|
||||
cnd = append(cnd, n)
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(f.Expression()); len(q) > 0 {
|
||||
if tmp, err = d.parseQuery(q); err != nil {
|
||||
return base.SetError(err)
|
||||
}
|
||||
|
||||
cnd = append(cnd, tmp)
|
||||
}
|
||||
|
||||
return base.Where(cnd...)
|
||||
}
|
||||
|
||||
func (m *model) aggregateSql(f filter.Filter, groupBy []dal.AggregateAttr, out []dal.AggregateAttr, having *ql.ASTNode) (q *goqu.SelectDataset) {
|
||||
// Get the base query and apply some filters
|
||||
q = m.dialect.AggregateBase(m.table, groupBy, out)
|
||||
q = m.applyFiltersToQuery(q, f)
|
||||
|
||||
var (
|
||||
err error
|
||||
expr exp.Expression
|
||||
|
||||
alias string
|
||||
|
||||
// store map alias-expression pairs to power-up
|
||||
// HAVING clause query parsing (but only for the
|
||||
// aggregation expression!)
|
||||
a2expr = make(map[string]exp.Expression)
|
||||
|
||||
selected []any
|
||||
|
||||
field = func(c dal.AggregateAttr) (expr exp.Expression, err error) {
|
||||
switch {
|
||||
case len(c.RawExpr) > 0:
|
||||
// @todo could probably be removed since RawExpr is only a temporary solution?
|
||||
return m.parseQuery(c.RawExpr)
|
||||
case c.Expression != nil:
|
||||
return m.convertQuery(c.Expression)
|
||||
}
|
||||
|
||||
return m.table.AttributeExpression(c.Identifier)
|
||||
}
|
||||
)
|
||||
|
||||
nc := m.dialect.Nuances()
|
||||
for i, c := range groupBy {
|
||||
if c.MultiValue {
|
||||
if nc.ExpandedJsonColumnSelector != nil {
|
||||
expr = nc.ExpandedJsonColumnSelector(c.RawExpr)
|
||||
} else {
|
||||
expr = goqu.C(c.RawExpr)
|
||||
}
|
||||
} else {
|
||||
if expr, err = field(c); err != nil {
|
||||
return q.SetError(err)
|
||||
}
|
||||
}
|
||||
|
||||
alias = c.Identifier
|
||||
if alias == "" {
|
||||
alias = fmt.Sprintf("group_by_%d", i)
|
||||
}
|
||||
|
||||
a2expr[alias] = expr
|
||||
|
||||
// grouping by selected
|
||||
q = q.GroupByAppend(alias)
|
||||
|
||||
expr = exp.NewAliasExpression(expr, alias)
|
||||
|
||||
// Add all group-by columns at the start of selection fields
|
||||
selected = append(selected, expr)
|
||||
}
|
||||
|
||||
q = q.Select(selected...)
|
||||
|
||||
for i, c := range out {
|
||||
if expr, err = field(c); err != nil {
|
||||
return q.SetError(err)
|
||||
}
|
||||
|
||||
alias = c.Identifier
|
||||
if alias == "" {
|
||||
alias = fmt.Sprintf("aggr_%d", i)
|
||||
}
|
||||
|
||||
a2expr[alias] = expr
|
||||
|
||||
expr = exp.NewAliasExpression(expr, alias)
|
||||
q = q.SelectAppend(expr)
|
||||
}
|
||||
|
||||
if having != nil {
|
||||
var (
|
||||
symHandler = m.qlConverterGenericSymHandler()
|
||||
|
||||
converter = ql.Converter(
|
||||
// we need a more specialized symbol handler for the HAVING clause
|
||||
// since it can contain aggregation expressions
|
||||
ql.SymHandler(func(node *ql.ASTNode) (exp.Expression, error) {
|
||||
sym := dal.NormalizeAttrNames(node.Symbol)
|
||||
|
||||
if a2expr[sym] != nil {
|
||||
if m.dialect.Nuances().HavingClauseMustUseAlias {
|
||||
// is aliased expression?
|
||||
return a2expr[sym], nil
|
||||
} else {
|
||||
return exp.NewIdentifierExpression("", "", sym), nil
|
||||
}
|
||||
}
|
||||
|
||||
// if not, use the default handler
|
||||
return symHandler(node)
|
||||
}),
|
||||
ql.RefHandler(m.qlConverterGenericRefHandler()),
|
||||
)
|
||||
)
|
||||
|
||||
// using special symbol handler that when converting HAVING clause expression
|
||||
// this handler looks at the
|
||||
if expr, err = converter.Convert(having); err != nil {
|
||||
q.SetError(err)
|
||||
return
|
||||
}
|
||||
|
||||
q = q.Having(expr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *model) lookupSql(pkv dal.ValueGetter) *goqu.SelectDataset {
|
||||
var (
|
||||
sel = d.selectSql().Limit(1)
|
||||
cond, err = d.pkLookupCondition(pkv)
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
sel = sel.SetError(err)
|
||||
}
|
||||
|
||||
return sel.Where(cond)
|
||||
}
|
||||
|
||||
func (d *model) selectSql() *goqu.SelectDataset {
|
||||
var (
|
||||
cols = d.table.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.dialect.GOQU().
|
||||
From(d.table.Ident())
|
||||
)
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(d.table.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(d.table.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func (d *model) truncateSql() (_ *goqu.TruncateDataset) {
|
||||
return d.dialect.GOQU().Truncate(d.table.Ident())
|
||||
}
|
||||
|
||||
func (d *model) insertSql(rr ...dal.ValueGetter) (_ *goqu.InsertDataset) {
|
||||
var (
|
||||
ins = d.dialect.GOQU().Insert(d.table.Ident())
|
||||
cc = d.table.Columns()
|
||||
|
||||
rows = make([][]any, len(rr))
|
||||
cols = make([]any, len(cc))
|
||||
|
||||
err error
|
||||
)
|
||||
|
||||
for c := range cc {
|
||||
cols[c] = cc[c].Name()
|
||||
}
|
||||
|
||||
for i, r := range rr {
|
||||
rows[i], err = d.table.Encode(r)
|
||||
if err != nil {
|
||||
return ins.SetError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return ins.Cols(cols...).Vals(rows...)
|
||||
}
|
||||
|
||||
// updateSql generates SQL command for updating record
|
||||
func (d *model) updateSql(r dal.ValueGetter) *goqu.UpdateDataset {
|
||||
var (
|
||||
upd = d.dialect.GOQU().Update(d.table.Ident())
|
||||
|
||||
values = exp.Record{}
|
||||
condition = exp.Ex{}
|
||||
|
||||
encoded, err = d.table.Encode(r)
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return upd.SetError(err)
|
||||
}
|
||||
|
||||
for i, c := range d.table.Columns() {
|
||||
if c.IsPrimaryKey() {
|
||||
// values[]
|
||||
condition[c.Name()] = encoded[i]
|
||||
} else {
|
||||
values[c.Name()] = encoded[i]
|
||||
}
|
||||
}
|
||||
|
||||
return upd.Where(condition).Set(values)
|
||||
}
|
||||
|
||||
func (d *model) deleteSql(pkv dal.ValueGetter) *goqu.DeleteDataset {
|
||||
var (
|
||||
del = d.dialect.GOQU().Delete(d.table.Ident())
|
||||
cond, err = d.pkLookupCondition(pkv)
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
del.SetError(err)
|
||||
}
|
||||
|
||||
return del.Where(cond)
|
||||
}
|
||||
|
||||
// Constructs primary-key-lookup expression from pk values
|
||||
func (d *model) pkLookupCondition(pkv dal.ValueGetter) (_ exp.Expression, err error) {
|
||||
var (
|
||||
cnd = exp.NewExpressionList(exp.AndType)
|
||||
val any
|
||||
)
|
||||
for _, c := range d.table.Columns() {
|
||||
if !c.IsPrimaryKey() {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err = pkv.GetValue(c.Name(), 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get value for primary key %q: %w", c.Name(), err)
|
||||
}
|
||||
|
||||
cnd = cnd.Append(exp.NewBooleanExpression(exp.EqOp, d.table.Ident().Col(c.Name()), val))
|
||||
}
|
||||
|
||||
return cnd, nil
|
||||
}
|
||||
760
server/store/adapters/rdbms/dal/model_test.go
Normal file
760
server/store/adapters/rdbms/dal/model_test.go
Normal file
@@ -0,0 +1,760 @@
|
||||
package dal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
. "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestModel_Search(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
const (
|
||||
items = 1000
|
||||
)
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
bm := time.Now()
|
||||
ctx = context.Background() // no need to log inserts
|
||||
count := 0
|
||||
for i := 1; i <= items; i++ {
|
||||
req.NoError(m.Create(ctx, &kv{
|
||||
"item": fmt.Sprintf("i%d", i),
|
||||
"group": fmt.Sprintf("g%d", i%1000),
|
||||
"price": i,
|
||||
"published": i%2 == 0,
|
||||
}))
|
||||
count++
|
||||
}
|
||||
|
||||
t.Logf("Inserted %d entries in %v", count, time.Now().Sub(bm))
|
||||
|
||||
t.Log("Search through records, filter out published")
|
||||
i, err = m.Search(filter.Generic(
|
||||
filter.WithExpression("published"),
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "group"},
|
||||
}),
|
||||
))
|
||||
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0, 5)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, items/2)
|
||||
req.Equal("group=g0 item=i1000 price=1000 published=1", rows[0].String())
|
||||
}
|
||||
|
||||
// Should be part of general DAL testing (not only RDBMS)
|
||||
func TestModel_Search2(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: func() (aa []*dal.Attribute) {
|
||||
s := &dal.CodecRecordValueSetJSON{Ident: "values"}
|
||||
aa = []*dal.Attribute{
|
||||
{Ident: "phyTxt", Type: &dal.TypeText{}},
|
||||
{Ident: "phyNum", Type: &dal.TypeNumber{}},
|
||||
{Ident: "phyBool", Type: &dal.TypeBoolean{}},
|
||||
{Ident: "jsonEncTxt", Type: &dal.TypeText{}, Store: s},
|
||||
{Ident: "jsonEncTxtMV", Type: &dal.TypeText{}, Store: s, MultiValue: true},
|
||||
{Ident: "jsonEncNum", Type: &dal.TypeNumber{}, Store: s},
|
||||
{Ident: "jsonEncNumMV", Type: &dal.TypeNumber{}, Store: s, MultiValue: true},
|
||||
{Ident: "jsonEncBool", Type: &dal.TypeBoolean{}, Store: s},
|
||||
{Ident: "jsonEncBoolMV", Type: &dal.TypeBoolean{}, Store: s, MultiValue: true},
|
||||
}
|
||||
|
||||
// iterate through all attributsand set Filterable flag to true
|
||||
for _, a := range aa {
|
||||
a.Filterable = true
|
||||
a.Sortable = true
|
||||
}
|
||||
|
||||
return
|
||||
}(),
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
search = func(t *testing.T, f filter.Filter) []kv {
|
||||
req := require.New(t)
|
||||
i, err := m.Search(f)
|
||||
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
rows := make([]kv, 0, 5)
|
||||
for i.Next(ctx) {
|
||||
row := kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
table, err := s.DataDefiner.ConvertModel(baseModel)
|
||||
req.NoError(err)
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
{
|
||||
noLogCtx := context.Background() // no need to log inserts
|
||||
req.NoError(m.Create(noLogCtx, (&kvv{}).
|
||||
Set("phyTxt", "bar").
|
||||
Set("phyNum", 42).
|
||||
Set("phyBool", false).
|
||||
Set("jsonEncTxt", "bar").
|
||||
Set("jsonEncTxtMV", "bar", "foo").
|
||||
Set("jsonEncNum", 42).
|
||||
Set("jsonEncNumMV", 21, 42).
|
||||
Set("jsonEncBool", false).
|
||||
Set("jsonEncBoolMV", false, true),
|
||||
))
|
||||
}
|
||||
|
||||
t.Run("comparing single value attributes", func(t *testing.T) {
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt = 'bar'"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt = 'baz'"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum = 42"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum = 21"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("!phyBool"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt = 'bar'"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt = 'baz'"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum = 42"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum = 21"))), 0)
|
||||
})
|
||||
|
||||
t.Run("comparing 1st value in multi value attributes", func(t *testing.T) {
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'bar'"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'baz'"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'foo'"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 21"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 22"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 42"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncBoolMV"))), 0)
|
||||
})
|
||||
|
||||
t.Run("comparing all values in multi value attributes", func(t *testing.T) {
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("42 IN jsonEncNumMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("84 IN jsonEncNumMV"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("84 NOT IN jsonEncNumMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("'foo' IN jsonEncTxtMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("'baz' IN jsonEncTxtMV"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("true IN jsonEncBoolMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("false IN jsonEncBoolMV"))), 1)
|
||||
})
|
||||
|
||||
t.Run("comparing all values in multi value attributes with another attribute", func(t *testing.T) {
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN jsonEncNumMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncNumMV"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncTxtMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN jsonEncTxtMV"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN jsonEncBoolMV"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncBoolMV"))), 0)
|
||||
})
|
||||
|
||||
t.Run("comparing with list of values", func(t *testing.T) {
|
||||
t.Skip("this is unsupported on pkg/ql level")
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN (null, 'bar')"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN (null, 'baz')"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN (null, 42)"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN (null, 21)"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("!phyBool IN (false))"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN (true))"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt IN (null, 'bar')"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt IN (null, 'baz')"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'bar')"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'baz')"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'foo')"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum IN (null, 42)"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum IN (null, 21)"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 21)"))), 1)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 22)"))), 0)
|
||||
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 42)"))), 0)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestModel_Aggregate(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
|
||||
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "30", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
|
||||
|
||||
t.Log("Aggregating all records, calculating min & max price per group")
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(
|
||||
filter.WithExpression("published"),
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "group", Descending: true},
|
||||
&filter.SortExpr{Column: "date", Descending: false},
|
||||
}),
|
||||
),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
},
|
||||
// aggregation expressions
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
|
||||
},
|
||||
nil, // <== here be having condition
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0, 3)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
// due to difference of number of decimal digits in different DBs, we need to do this
|
||||
// to make sure we get the same result
|
||||
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
|
||||
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, 3)
|
||||
req.Equal("avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00", rows[0].String())
|
||||
req.Equal("avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00", rows[1].String())
|
||||
req.Equal("avg=2000.00 count=2 date=2022-10-06 group=g1 max=3000 min=1000 stock=40.00", rows[2].String())
|
||||
}
|
||||
|
||||
func TestModel_AggregationModelIdentifiers(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: "t",
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "group", Type: &dal.TypeText{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"group": "g1"}))
|
||||
req.NoError(m.Create(ctx, &kv{"group": "g1"}))
|
||||
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
|
||||
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
|
||||
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
|
||||
|
||||
t.Log("Aggregating all records, calculating min & max price per group")
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "grp", Descending: true},
|
||||
}),
|
||||
),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "grp", RawExpr: "CONCAT(group, 'x')", Type: &dal.TypeText{}},
|
||||
},
|
||||
// aggregation expressions
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "total", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
|
||||
},
|
||||
// having
|
||||
qlParse(req, "total = 3"),
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, 1)
|
||||
req.Equal("grp=g2x total=3", rows[0].String())
|
||||
}
|
||||
|
||||
func TestModel_AggregateWithHaving(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
|
||||
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "0", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "0", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
|
||||
|
||||
t.Log("Aggregating all records, calculating min & max price per group, ignoring empty quantity")
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(
|
||||
filter.WithExpression("published"),
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "group", Descending: true},
|
||||
&filter.SortExpr{Column: "date", Descending: false},
|
||||
}),
|
||||
),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
},
|
||||
// aggregation expressions
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
|
||||
},
|
||||
// having
|
||||
qlParse(req, "SUM(quantity) > 0"),
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0, 3)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
// due to difference of number of decimal digits in different DBs, we need to do this
|
||||
// to make sure we get the same result
|
||||
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
|
||||
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, 2)
|
||||
req.Equal("avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00", rows[0].String())
|
||||
req.Equal("avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00", rows[1].String())
|
||||
}
|
||||
|
||||
func TestModel_AggregateHavingGroup(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "grp", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true, Filterable: true},
|
||||
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
|
||||
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "grp": "g1", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "grp": "g1", "price": "3000", "quantity": "30", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "grp": "g2", "price": "4000", "quantity": "40", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "grp": "g2", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "grp": "g2", "price": "1000", "quantity": "10", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "grp": "g2", "price": "5000", "quantity": "50", "published": true}))
|
||||
|
||||
t.Log("Aggregating all records, calculating min & max price per group")
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "agg", Expression: &ql.ASTNode{Symbol: "grp"}, Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
},
|
||||
// aggregation expressions
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
|
||||
},
|
||||
qlParse(req, "agg = 'g1'"),
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0, 1)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, 1)
|
||||
req.Equal("agg=g1 count=2", rows[0].String())
|
||||
}
|
||||
|
||||
func TestModel_Distinct(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i1", "group": "g1", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i2", "group": "g1", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i3", "group": "g2", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i4", "group": "g2", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i5", "group": "g3", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i6", "group": "g3", "published": false}))
|
||||
|
||||
t.Log("Aggregating all records, returning distinct groups")
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(
|
||||
filter.WithExpression("published"),
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "group"},
|
||||
})),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
},
|
||||
nil,
|
||||
nil, // <== here be having condition
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
t.Log("Iterating over results")
|
||||
rows := make([]kv, 0, 2)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
req.Len(rows, 2)
|
||||
req.Equal("group=g1", rows[0].String())
|
||||
req.Equal("group=g2", rows[1].String())
|
||||
}
|
||||
|
||||
func TestModel_AggregateWithCursors(t *testing.T) {
|
||||
_ = logger.Default()
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
baseModel = &dal.Model{
|
||||
Ident: t.Name(),
|
||||
Attributes: []*dal.Attribute{
|
||||
{Ident: "item", Type: &dal.TypeText{}},
|
||||
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
|
||||
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
|
||||
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
|
||||
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
|
||||
},
|
||||
}
|
||||
|
||||
m = Model(baseModel, s.DB, s.Dialect)
|
||||
|
||||
i dal.Iterator
|
||||
|
||||
table, err = s.DataDefiner.ConvertModel(baseModel)
|
||||
row kv
|
||||
|
||||
cur *filter.PagingCursor
|
||||
|
||||
results = []string{
|
||||
"avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00",
|
||||
"avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00",
|
||||
"",
|
||||
}
|
||||
)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("Creating temporary table %q", table.Ident)
|
||||
table.Temporary = true
|
||||
req.NoError(s.DataDefiner.TableCreate(ctx, table))
|
||||
req.NoError(truncate(ctx, table.Ident))
|
||||
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "0", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "0", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
|
||||
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
|
||||
|
||||
// fetching two pages
|
||||
for p, result := range results {
|
||||
t.Logf("#%d Aggregating all records, calculating min & max price per group, ignoring empty quantity", p+1)
|
||||
i, err = m.Aggregate(
|
||||
filter.Generic(
|
||||
filter.WithCursor(cur),
|
||||
filter.WithLimit(1),
|
||||
filter.WithExpression("published"),
|
||||
filter.WithOrderBy(filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "group", Descending: true},
|
||||
&filter.SortExpr{Column: "date", Descending: false},
|
||||
}),
|
||||
),
|
||||
// group-by
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
|
||||
},
|
||||
// aggregation expressions
|
||||
[]dal.AggregateAttr{
|
||||
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
|
||||
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
|
||||
},
|
||||
// having
|
||||
qlParse(req, "SUM(quantity) > 0"),
|
||||
)
|
||||
req.NoError(err)
|
||||
req.NotNil(i)
|
||||
|
||||
defer req.NoError(i.Close())
|
||||
|
||||
// uncomment to se generated query
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
t.Logf("#%d Iterating over results", p+1)
|
||||
rows := make([]kv, 0, 3)
|
||||
for i.Next(ctx) {
|
||||
row = kv{}
|
||||
req.NoError(i.Scan(row))
|
||||
|
||||
// due to difference of number of decimal digits in different DBs, we need to do this
|
||||
// to make sure we get the same result
|
||||
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
|
||||
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
req.NoError(i.Err())
|
||||
if len(result) == 0 {
|
||||
req.Len(rows, 0)
|
||||
} else {
|
||||
req.Len(rows, 1)
|
||||
req.Equal(result, rows[0].String())
|
||||
|
||||
cur, err = i.ForwardCursor(row)
|
||||
req.NotNil(cur)
|
||||
req.NoError(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
339
server/store/adapters/rdbms/ddl/commands.go
Normal file
339
server/store/adapters/rdbms/ddl/commands.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package ddl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
dialect interface {
|
||||
QuoteIdent(i string) string
|
||||
}
|
||||
|
||||
CreateTable struct {
|
||||
Dialect dialect
|
||||
Table *Table
|
||||
OmitIfNotExistsClause bool
|
||||
SuffixClause string
|
||||
}
|
||||
|
||||
DropTable struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
}
|
||||
|
||||
CreateIndex struct {
|
||||
Dialect dialect
|
||||
Index *Index
|
||||
OmitIfNotExistsClause bool
|
||||
OmitFieldLength bool
|
||||
}
|
||||
|
||||
DropIndex struct {
|
||||
Dialect dialect
|
||||
Ident string
|
||||
TableIdent string
|
||||
}
|
||||
|
||||
AddColumn struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
Column *Column
|
||||
}
|
||||
|
||||
DropColumn struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
Column string
|
||||
}
|
||||
|
||||
RenameColumn struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
ReTypeColumn struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
Column string
|
||||
Type *ColumnType
|
||||
}
|
||||
|
||||
DropConstraint struct {
|
||||
Dialect dialect
|
||||
Table string
|
||||
Constraint string
|
||||
}
|
||||
)
|
||||
|
||||
// Exec is a utility for executing series of commands
|
||||
//
|
||||
// # Parameters can be string, Stringer interface or goqu's exp.SQLExpression
|
||||
//
|
||||
// Any other type will result in panic
|
||||
func Exec(ctx context.Context, db sqlx.ExtContext, ss ...any) (err error) {
|
||||
for _, s := range ss {
|
||||
var (
|
||||
sql string
|
||||
args []any
|
||||
)
|
||||
|
||||
switch c := s.(type) {
|
||||
case interface{ ToSQL() (string, []any, error) }:
|
||||
sql, args, err = c.ToSQL()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case fmt.Stringer:
|
||||
sql = c.String()
|
||||
case string:
|
||||
sql = c
|
||||
default:
|
||||
panic(fmt.Sprintf("unexecutable input (%T)", s))
|
||||
}
|
||||
|
||||
if _, err = db.ExecContext(ctx, sql, args...); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *CreateTable) String() string {
|
||||
sql := "CREATE "
|
||||
if t.Table.Temporary {
|
||||
sql += "TEMPORARY "
|
||||
}
|
||||
|
||||
sql += "TABLE "
|
||||
if !t.OmitIfNotExistsClause {
|
||||
sql += "IF NOT EXISTS "
|
||||
}
|
||||
|
||||
sql += t.Dialect.QuoteIdent(t.Table.Ident) + " (\n"
|
||||
sql += GenCreateTableBody(t.Dialect, t.Table)
|
||||
sql += ")"
|
||||
sql += t.SuffixClause
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func (c *DropTable) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`DROP TABLE %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func GenCreateTableBody(d dialect, t *Table) string {
|
||||
sql := ""
|
||||
|
||||
for c, col := range t.Columns {
|
||||
if c == 0 {
|
||||
sql += " "
|
||||
} else {
|
||||
sql += ", "
|
||||
}
|
||||
|
||||
sql += GenTableColumn(d, col)
|
||||
|
||||
sql += "\n"
|
||||
}
|
||||
|
||||
// check if any of the indexes is a primary key
|
||||
for _, pk := range t.Indexes {
|
||||
if pk.Ident != PRIMARY_KEY {
|
||||
continue
|
||||
}
|
||||
|
||||
sql += "\n"
|
||||
sql += ", " + GenPrimaryKey(d, pk) + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func GenTableColumn(d dialect, col *Column) string {
|
||||
sql := d.QuoteIdent(col.Ident) + " " + col.Type.Name + " "
|
||||
|
||||
if col.Type.Null {
|
||||
sql += " NULL"
|
||||
} else {
|
||||
sql += "NOT NULL"
|
||||
}
|
||||
|
||||
if col.Default > "" {
|
||||
sql += " DEFAULT " + col.Default
|
||||
}
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func GenPrimaryKey(d dialect, pk *Index) string {
|
||||
sql := "PRIMARY KEY ("
|
||||
for f, field := range pk.Fields {
|
||||
if f > 0 {
|
||||
sql += ", "
|
||||
}
|
||||
|
||||
sql += d.QuoteIdent(field.Column)
|
||||
}
|
||||
sql += ")"
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func (t *CreateIndex) String() string {
|
||||
sql := "CREATE "
|
||||
|
||||
if t.Index.Unique {
|
||||
sql += "UNIQUE "
|
||||
}
|
||||
|
||||
sql += "INDEX "
|
||||
|
||||
if !t.OmitIfNotExistsClause {
|
||||
sql += "IF NOT EXISTS "
|
||||
}
|
||||
|
||||
sql += t.Dialect.QuoteIdent(t.Index.Ident) + " ON " + t.Dialect.QuoteIdent(t.Index.TableIdent) + " ("
|
||||
|
||||
for f, field := range t.Index.Fields {
|
||||
isExpr := len(field.Expression) > 0
|
||||
|
||||
if f > 0 {
|
||||
sql += ", "
|
||||
}
|
||||
|
||||
if isExpr {
|
||||
sql += "("
|
||||
sql += field.Expression
|
||||
} else {
|
||||
sql += t.Dialect.QuoteIdent(field.Column)
|
||||
}
|
||||
|
||||
if field.Length > 0 && !t.OmitFieldLength {
|
||||
sql += fmt.Sprintf("(%d)", field.Length)
|
||||
}
|
||||
|
||||
if isExpr {
|
||||
sql += ")"
|
||||
}
|
||||
|
||||
switch field.Sort {
|
||||
case dal.IndexFieldSortDesc:
|
||||
sql += " DESC"
|
||||
case dal.IndexFieldSortAsc:
|
||||
sql += " ASC"
|
||||
}
|
||||
|
||||
switch field.Nulls {
|
||||
case dal.IndexFieldNullsLast:
|
||||
sql += " NULLS LAST"
|
||||
case dal.IndexFieldNullsFirst:
|
||||
sql += " NULLS FIRST"
|
||||
}
|
||||
|
||||
}
|
||||
sql += ")"
|
||||
|
||||
if t.Index.Predicate != "" {
|
||||
sql += " WHERE " + t.Index.Predicate
|
||||
}
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func (c *AddColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
sql = fmt.Sprintf(
|
||||
`ALTER TABLE %s ADD COLUMN %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column.Ident),
|
||||
c.Column.Type.Name,
|
||||
)
|
||||
|
||||
if !c.Column.Type.Null {
|
||||
sql += " NOT NULL"
|
||||
}
|
||||
|
||||
if len(c.Column.Default) > 0 {
|
||||
// @todo right now we can (and need to) trust that default
|
||||
// values are unharmful!
|
||||
sql += " DEFAULT " + c.Column.Default
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *DropColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s DROP COLUMN %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func (c *DropIndex) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`DROP INDEX %s ON %s`,
|
||||
c.Dialect.QuoteIdent(c.Ident),
|
||||
c.Dialect.QuoteIdent(c.TableIdent),
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func (c *RenameColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s RENAME COLUMN %s TO %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Old),
|
||||
c.Dialect.QuoteIdent(c.New),
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func (c *ReTypeColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s MODIFY COLUMN %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
c.Type.Name,
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
// GetBool is a utility function to simplify getting a boolean value from a query result.
|
||||
func GetBool(ctx context.Context, db sqlx.QueryerContext, query exp.SQLExpression) (bool, error) {
|
||||
var (
|
||||
exists bool
|
||||
sql, args, err = query.ToSQL()
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("could not generate SQL: %v", err)
|
||||
}
|
||||
|
||||
if err = sqlx.GetContext(ctx, db, &exists, sql, args...); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// Structs is a utility function to simplify selecting data into slice of structs
|
||||
func Structs(ctx context.Context, db sqlx.QueryerContext, query exp.SQLExpression, t any) error {
|
||||
var (
|
||||
sql, args, err = query.ToSQL()
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate SQL: %v", err)
|
||||
}
|
||||
|
||||
return sqlx.SelectContext(ctx, db, t, sql, args...)
|
||||
}
|
||||
70
server/store/adapters/rdbms/ddl/dal_model.go
Normal file
70
server/store/adapters/rdbms/ddl/dal_model.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package ddl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
)
|
||||
|
||||
// CreateModel creates table with columns and indexes that match Model definition
|
||||
func CreateModel(ctx context.Context, dd DataDefiner, m *dal.Model) (err error) {
|
||||
var (
|
||||
t *Table
|
||||
)
|
||||
|
||||
if t, err = dd.ConvertModel(m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = dd.TableCreate(ctx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = EnsureIndexes(ctx, dd, m.Indexes...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteModel(ctx context.Context, dd DataDefiner, m *dal.Model) (err error) {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// UpdateModel alters existing table's columns and indexes to match Model definition
|
||||
func UpdateModel(ctx context.Context, dd DataDefiner, m *dal.Model) (err error) {
|
||||
var (
|
||||
t *Table
|
||||
col *Column
|
||||
colIdent string
|
||||
)
|
||||
|
||||
if t, err = dd.TableLookup(ctx, m.Ident); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// @todo check model against table structure
|
||||
for _, attr := range m.Attributes {
|
||||
// iterate over attributes and check if they exist in the table
|
||||
colIdent = attr.StoreIdent()
|
||||
col = t.ColumnByIdent(colIdent)
|
||||
if col != nil {
|
||||
// @todo check if column type matches
|
||||
// @todo check if column is nullable
|
||||
continue
|
||||
}
|
||||
|
||||
// @todo add column to table
|
||||
return fmt.Errorf("column %q on table %q not found; adding columns is not yet supported", attr.Ident, m.Ident)
|
||||
}
|
||||
|
||||
if err = EnsureIndexes(ctx, dd, m.Indexes...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func EnsureIndexes(ctx context.Context, dd DataDefiner, ii ...*dal.Index) (err error) {
|
||||
return nil
|
||||
}
|
||||
289
server/store/adapters/rdbms/ddl/data_definer.go
Normal file
289
server/store/adapters/rdbms/ddl/data_definer.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package ddl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
)
|
||||
|
||||
type (
|
||||
driverDialect interface {
|
||||
QuoteIdent(string) string
|
||||
// AttributeToColumn converts attribute to column
|
||||
AttributeToColumn(*dal.Attribute) (*Column, error)
|
||||
IndexFieldModifiers(*dal.Attribute, ...dal.IndexFieldModifier) (string, error)
|
||||
}
|
||||
|
||||
// DataDefiner describes an interface for all DDL commands
|
||||
DataDefiner interface {
|
||||
ConvertModel(*dal.Model) (*Table, error)
|
||||
ConvertAttribute(attr *dal.Attribute) (*Column, error)
|
||||
|
||||
// Tables(ctx context.Context) ([]*Table, error)
|
||||
TableLookup(context.Context, string) (*Table, error)
|
||||
TableCreate(context.Context, *Table) error
|
||||
TableDrop(context.Context, string) error
|
||||
|
||||
ColumnAdd(context.Context, string, *Column) error
|
||||
ColumnDrop(context.Context, string, string) error
|
||||
ColumnRename(context.Context, string, string, string) error
|
||||
ColumnReType(context.Context, string, string, *ColumnType) error
|
||||
|
||||
IndexLookup(context.Context, string, string) (*Index, error)
|
||||
IndexCreate(context.Context, string, *Index) error
|
||||
IndexDrop(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// Table describes structure of the SQL table
|
||||
Table struct {
|
||||
Ident string
|
||||
Columns []*Column
|
||||
Indexes []*Index
|
||||
Comment string
|
||||
|
||||
Temporary bool
|
||||
|
||||
// implementation variations
|
||||
Meta map[string]interface{}
|
||||
}
|
||||
|
||||
Column struct {
|
||||
Ident string
|
||||
Type *ColumnType
|
||||
|
||||
Default string
|
||||
|
||||
// implementation variations
|
||||
Meta map[string]interface{}
|
||||
|
||||
Comment string
|
||||
}
|
||||
|
||||
ColumnType struct {
|
||||
Name string
|
||||
|
||||
Null bool
|
||||
|
||||
// implementation variations
|
||||
Meta map[string]interface{}
|
||||
}
|
||||
|
||||
// Index describes structure of the SQL index
|
||||
Index struct {
|
||||
TableIdent string
|
||||
Ident string
|
||||
Type string
|
||||
Fields []*IndexField
|
||||
Unique bool
|
||||
Predicate string
|
||||
Comment string
|
||||
|
||||
// implementation variations
|
||||
Meta map[string]interface{}
|
||||
}
|
||||
|
||||
// IndexField describes a single field (column or expression) of the SQL index
|
||||
IndexField struct {
|
||||
// Expression or a single column
|
||||
Column string
|
||||
|
||||
Length int
|
||||
|
||||
// Wrap part in parentheses
|
||||
Expression string
|
||||
|
||||
// Ascending or descending
|
||||
Sort dal.IndexFieldSort
|
||||
Nulls dal.IndexFieldNulls
|
||||
|
||||
Statistics *IndexFieldStatistics
|
||||
|
||||
// implementation variations
|
||||
Meta map[string]interface{}
|
||||
}
|
||||
|
||||
IndexFieldStatistics struct {
|
||||
// Cardinality is an indicator that refers to the uniqueness
|
||||
// of all values in a column. Low cardinality means a lot
|
||||
// of duplicate values in that column. For example, a column
|
||||
// that stores the gender values has low cardinality.
|
||||
// In contrast, high cardinality means that there are many distinct values.
|
||||
Cardinality int64
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
PRIMARY_KEY = "PRIMARY"
|
||||
)
|
||||
|
||||
func (t *Table) ColumnByIdent(i string) *Column {
|
||||
for _, c := range t.Columns {
|
||||
if c.Ident == i {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertModel is generic model converter
|
||||
func ConvertModel(m *dal.Model, d driverDialect) (t *Table, err error) {
|
||||
var (
|
||||
col *Column
|
||||
idx *Index
|
||||
|
||||
// keeps track of embedded attributes
|
||||
// key is storeIdent
|
||||
embeds = make(map[string]bool)
|
||||
)
|
||||
|
||||
t = &Table{Ident: m.Ident}
|
||||
for _, a := range m.Attributes {
|
||||
if a.Type == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if embeds[a.StoreIdent()] {
|
||||
continue
|
||||
}
|
||||
|
||||
switch a.Store.(type) {
|
||||
case *dal.CodecRecordValueSetJSON:
|
||||
// add to embeds and make sure we do not add the column again!
|
||||
embeds[a.StoreIdent()] = true
|
||||
|
||||
// throw away the attribute and create a new one
|
||||
// to for JSON storage
|
||||
a = &dal.Attribute{
|
||||
Ident: a.StoreIdent(),
|
||||
Type: &dal.TypeJSON{Nullable: false},
|
||||
Store: &dal.CodecPlain{},
|
||||
}
|
||||
}
|
||||
|
||||
if col, err = ConvertAttribute(a, d); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.Columns = append(t.Columns, col)
|
||||
}
|
||||
|
||||
for _, i := range m.Indexes {
|
||||
if idx, err = ConvertIndex(i, m.Attributes, m.Ident, d); err != nil {
|
||||
return nil, fmt.Errorf("could not convert index %q: %w", i.Ident, err)
|
||||
}
|
||||
|
||||
t.Indexes = append(t.Indexes, idx)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func ConvertAttribute(attr *dal.Attribute, d driverDialect) (col *Column, err error) {
|
||||
col, err = d.AttributeToColumn(attr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not convert attribute %q to column: %w", attr.Ident, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ConvertIndex converts dal.Index to ddl.Index
|
||||
func ConvertIndex(i *dal.Index, aa dal.AttributeSet, table string, d driverDialect) (idx *Index, err error) {
|
||||
var (
|
||||
a *dal.Attribute
|
||||
idxField *IndexField
|
||||
)
|
||||
|
||||
idx = &Index{
|
||||
TableIdent: table,
|
||||
Ident: i.Ident,
|
||||
Type: i.Type,
|
||||
Unique: i.Unique,
|
||||
Predicate: i.Predicate,
|
||||
}
|
||||
|
||||
for _, f := range i.Fields {
|
||||
// ensure attribute exists
|
||||
if a = aa.FindByIdent(f.AttributeIdent); a == nil {
|
||||
return nil, fmt.Errorf("referenced attribute %q does not exist", f.AttributeIdent)
|
||||
}
|
||||
|
||||
idxField = &IndexField{
|
||||
Sort: f.Sort,
|
||||
Nulls: f.Nulls,
|
||||
}
|
||||
|
||||
if len(f.Modifiers) > 0 {
|
||||
if idxField.Expression, err = d.IndexFieldModifiers(a, f.Modifiers...); err != nil {
|
||||
return nil, fmt.Errorf("could not convert index field modifiers: %w", err)
|
||||
}
|
||||
} else {
|
||||
idxField.Column = a.StoreIdent()
|
||||
}
|
||||
|
||||
idx.Fields = append(idx.Fields, idxField)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func DefaultValueCurrentTimestamp(set bool) string {
|
||||
if !set {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "CURRENT_TIMESTAMP"
|
||||
}
|
||||
|
||||
func DefaultBoolean(set, value bool) string {
|
||||
switch {
|
||||
case !set:
|
||||
return ""
|
||||
case value:
|
||||
return "true"
|
||||
default:
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultID(set bool, value uint64) string {
|
||||
switch {
|
||||
case !set:
|
||||
return ""
|
||||
default:
|
||||
return strconv.FormatUint(value, 10)
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultNumber(set bool, precision int, value float64) string {
|
||||
switch {
|
||||
case !set:
|
||||
return ""
|
||||
case precision >= 0:
|
||||
return strconv.FormatFloat(value, 'f', precision, 64)
|
||||
default:
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultJSON(set bool, value any) (_ string, err error) {
|
||||
if str, is := value.(string); is {
|
||||
return "'" + str + "'", nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case !set:
|
||||
return "", nil
|
||||
default:
|
||||
var aux []byte
|
||||
if aux, err = json.Marshal(value); err != nil {
|
||||
return "", fmt.Errorf("could not serialize default value for JSON field: %w", err)
|
||||
}
|
||||
|
||||
return string(aux), nil
|
||||
}
|
||||
}
|
||||
163
server/store/adapters/rdbms/ddl/data_definer_test.go
Normal file
163
server/store/adapters/rdbms/ddl/data_definer_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package ddl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
mockDriver struct{}
|
||||
)
|
||||
|
||||
func (mockDriver) QuoteIdent(i string) string { return `"` + i + `"` }
|
||||
|
||||
func (mockDriver) AttributeToColumn(attr *dal.Attribute) (col *Column, err error) {
|
||||
col = &Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
_ = t
|
||||
col.Type.Name = "INT"
|
||||
case *dal.TypeText:
|
||||
_ = t
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
return col, nil
|
||||
}
|
||||
|
||||
func (d mockDriver) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
// Copied over to avoid import cycle
|
||||
// @todo improve
|
||||
func IndexFieldModifiers(attr *dal.Attribute, quoteIdent func(i string) string, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
var (
|
||||
modifier string
|
||||
out = quoteIdent(attr.StoreIdent())
|
||||
)
|
||||
|
||||
for _, m := range mm {
|
||||
switch m {
|
||||
case dal.IndexFieldModifierLower:
|
||||
modifier = "LOWER"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown index field modifier: %s", m)
|
||||
}
|
||||
|
||||
out = fmt.Sprintf("%s(%s)", modifier, out)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestModelToTable(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
m *dal.Model
|
||||
d driverDialect
|
||||
|
||||
createTableSQL string
|
||||
createIndexSQL string
|
||||
}{
|
||||
{
|
||||
name: "simple",
|
||||
d: mockDriver{},
|
||||
m: &dal.Model{
|
||||
Ident: "simple",
|
||||
Attributes: dal.AttributeSet{
|
||||
&dal.Attribute{
|
||||
Ident: "null_ID",
|
||||
Type: &dal.TypeID{
|
||||
Nullable: true,
|
||||
},
|
||||
},
|
||||
&dal.Attribute{
|
||||
Ident: "some_txt",
|
||||
Type: &dal.TypeText{},
|
||||
},
|
||||
},
|
||||
|
||||
Indexes: []*dal.Index{
|
||||
{
|
||||
Ident: PRIMARY_KEY,
|
||||
Fields: []*dal.IndexField{
|
||||
{
|
||||
AttributeIdent: "null_ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Ident: "first_idx",
|
||||
Fields: []*dal.IndexField{
|
||||
{
|
||||
AttributeIdent: "null_ID",
|
||||
Modifiers: []dal.IndexFieldModifier{dal.IndexFieldModifierLower},
|
||||
Sort: dal.IndexFieldSortDesc,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Ident: "second_idx",
|
||||
Fields: []*dal.IndexField{
|
||||
{
|
||||
AttributeIdent: "null_ID",
|
||||
},
|
||||
{
|
||||
AttributeIdent: "some_txt",
|
||||
Modifiers: []dal.IndexFieldModifier{dal.IndexFieldModifierLower},
|
||||
Sort: dal.IndexFieldSortDesc,
|
||||
Nulls: dal.IndexFieldNullsLast,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
createTableSQL: `
|
||||
CREATE TABLE IF NOT EXISTS "simple" (
|
||||
"null_ID" INT NULL
|
||||
, "some_txt" TEXT NOT NULL
|
||||
|
||||
, PRIMARY KEY ("null_ID")
|
||||
)`,
|
||||
|
||||
createIndexSQL: `
|
||||
CREATE INDEX IF NOT EXISTS "first_idx" ON "simple" ((LOWER("null_ID")) DESC)
|
||||
CREATE INDEX IF NOT EXISTS "second_idx" ON "simple" ("null_ID", (LOWER("some_txt")) DESC NULLS LAST)
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req = require.New(t)
|
||||
|
||||
tbl, err := ConvertModel(tt.m, tt.d)
|
||||
req.NoError(err)
|
||||
|
||||
req.Equal(
|
||||
strings.TrimSpace(tt.createTableSQL),
|
||||
strings.TrimSpace((&CreateTable{Table: tbl, Dialect: tt.d}).String()),
|
||||
)
|
||||
|
||||
idxSQL := "\n"
|
||||
for i := 1; i < len(tbl.Indexes); i++ {
|
||||
idxSQL += (&CreateIndex{Index: tbl.Indexes[i], Dialect: tt.d}).String() + "\n"
|
||||
}
|
||||
|
||||
req.Equal(tt.createIndexSQL, idxSQL)
|
||||
})
|
||||
}
|
||||
}
|
||||
23
server/store/adapters/rdbms/ddl/goqu.go
Normal file
23
server/store/adapters/rdbms/ddl/goqu.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package ddl
|
||||
|
||||
import "github.com/doug-martin/goqu/v9/exp"
|
||||
|
||||
// goqu's little helpers
|
||||
|
||||
type (
|
||||
// literalExpression is a wrapper around goqu's LiteralExpression
|
||||
sqlExpression struct {
|
||||
exp.LiteralExpression
|
||||
}
|
||||
)
|
||||
|
||||
func SQLExpression(e exp.LiteralExpression) exp.SQLExpression {
|
||||
return &sqlExpression{e}
|
||||
}
|
||||
|
||||
func (e *sqlExpression) ToSQL() (string, []any, error) { return e.Literal(), e.Args(), nil }
|
||||
func (e *sqlExpression) IsPrepared() bool { return e.IsPrepared() }
|
||||
|
||||
var (
|
||||
_ exp.SQLExpression = &sqlExpression{}
|
||||
)
|
||||
18
server/store/adapters/rdbms/ddl/types.go
Normal file
18
server/store/adapters/rdbms/ddl/types.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package ddl
|
||||
|
||||
import "strings"
|
||||
|
||||
func ParseColumnTypes(c *Column) (original, name string, meta []string) {
|
||||
original = strings.ToLower(c.Type.Name)
|
||||
|
||||
pp := strings.Split(original, "(")
|
||||
name = pp[0]
|
||||
if len(pp) > 1 {
|
||||
meta = strings.Split(strings.TrimRight(pp[1], ")"), ",")
|
||||
for i, m := range meta {
|
||||
meta[i] = strings.TrimSpace(m)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
79
server/store/adapters/rdbms/drivers/cast.go
Normal file
79
server/store/adapters/rdbms/drivers/cast.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
CheckID = exp.NewLiteralExpression(`'^[0-9]+$'`)
|
||||
CheckNumber = exp.NewLiteralExpression(`'^[+-]?[0-9]+(\.[0-9]+)?$'`)
|
||||
CheckFullISO8061 = exp.NewLiteralExpression(`'^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?)$'`)
|
||||
CheckDateISO8061 = exp.NewLiteralExpression(`'^([0-9]{4})-([0-9]{2})-([0-9]{2})(T([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?))?$'`)
|
||||
CheckTimeISO8061 = exp.NewLiteralExpression(`'^(([0-9]{4})-([0-9]{2})-([0-9]{2}))?T?([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?)$'`)
|
||||
LiteralNULL = exp.NewLiteralExpression(`NULL`)
|
||||
LiteralFALSE = exp.NewLiteralExpression(`FALSE`)
|
||||
LiteralTRUE = exp.NewLiteralExpression(`TRUE`)
|
||||
)
|
||||
|
||||
func RegexpLike(format, val exp.Expression) exp.BooleanExpression {
|
||||
return exp.NewBooleanExpression(exp.RegexpLikeOp, val, format)
|
||||
}
|
||||
|
||||
func BooleanCheck(val exp.Expression) exp.Expression {
|
||||
return exp.NewCaseExpression().
|
||||
When(exp.NewBooleanExpression(exp.InOp, val, []any{LiteralTRUE, exp.NewLiteralExpression(`'true'`)}), LiteralTRUE).
|
||||
When(exp.NewBooleanExpression(exp.InOp, val, []any{LiteralFALSE, exp.NewLiteralExpression(`'false'`)}), LiteralFALSE).
|
||||
Else(LiteralNULL)
|
||||
}
|
||||
|
||||
func AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.CastExpression
|
||||
)
|
||||
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckID, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "BIGINT")
|
||||
|
||||
case *dal.TypeNumber:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckNumber, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "NUMERIC")
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckFullISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIMESTAMPTZ")
|
||||
|
||||
case *dal.TypeDate:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckDateISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DATE")
|
||||
|
||||
case *dal.TypeTime:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckTimeISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIMETZ")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
c = exp.NewCastExpression(BooleanCheck(val), "BOOLEAN")
|
||||
|
||||
default:
|
||||
return val, nil
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
}
|
||||
306
server/store/adapters/rdbms/drivers/columns.go
Normal file
306
server/store/adapters/rdbms/drivers/columns.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type (
|
||||
Column interface {
|
||||
Name() string
|
||||
Attribute() *dal.Attribute
|
||||
IsPrimaryKey() bool
|
||||
Encode(dal.ValueGetter) (any, error)
|
||||
Decode(any, dal.ValueSetter) error
|
||||
Type() Type
|
||||
}
|
||||
|
||||
SingleValueColumn struct {
|
||||
typ Type
|
||||
name string
|
||||
attr *dal.Attribute
|
||||
}
|
||||
|
||||
SimpleJsonDocColumn struct {
|
||||
name string
|
||||
attributes []*dal.Attribute
|
||||
}
|
||||
)
|
||||
|
||||
func NewSingleValueColumn(d Dialect, a *dal.Attribute) *SingleValueColumn {
|
||||
return &SingleValueColumn{
|
||||
typ: d.TypeWrap(a.Type),
|
||||
attr: a,
|
||||
name: a.StoreIdent(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) IsPrimaryKey() bool {
|
||||
return c.attr.PrimaryKey
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Attribute() *dal.Attribute {
|
||||
return c.attr
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Type() Type {
|
||||
return c.typ
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Encode(r dal.ValueGetter) (any, error) {
|
||||
val, err := r.GetValue(c.attr.Ident, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.typ.Encode(val)
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Decode(raw any, r dal.ValueSetter) error {
|
||||
value, valid, err := c.typ.Decode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch c.attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(value) {
|
||||
value = "1"
|
||||
} else {
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
|
||||
ident := c.attr.Ident
|
||||
if !valid {
|
||||
return r.SetValue(ident, 0, nil)
|
||||
}
|
||||
|
||||
return r.SetValue(ident, 0, value)
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) IsPrimaryKey() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Attribute() *dal.Attribute {
|
||||
return c.attributes[0]
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Type() Type {
|
||||
return &TypeJSON{}
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Encode(r dal.ValueGetter) (_ any, err error) {
|
||||
var (
|
||||
aux = make(map[string][]any)
|
||||
value any
|
||||
place uint
|
||||
|
||||
// determinate how many values can we expected to store in the attribute
|
||||
//
|
||||
// implementations can return nil for the value to signal
|
||||
// that each attribute holds exactly one value
|
||||
count = r.CountValues()
|
||||
|
||||
// preset this to one just in case CountValues()
|
||||
// returns nil!
|
||||
size uint = 1
|
||||
)
|
||||
|
||||
for _, attr := range c.attributes {
|
||||
if count != nil {
|
||||
size = count[attr.Ident]
|
||||
}
|
||||
|
||||
aux[attr.Ident] = make([]any, size)
|
||||
|
||||
for place = 0; place < size; place++ {
|
||||
value, err = r.GetValue(attr.Ident, place)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// we want booleans stored as booleans
|
||||
aux[attr.Ident][place] = cast.ToBool(value)
|
||||
|
||||
default:
|
||||
// every other value is
|
||||
aux[attr.Ident][place] = value
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Decode(raw any, r dal.ValueSetter) (err error) {
|
||||
rawJson, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return fmt.Errorf("incompatible input value type (%T), expecting *sql.RawBytes", raw)
|
||||
}
|
||||
|
||||
if len(*rawJson) == 0 {
|
||||
// gracefully handle empty strings as valid input
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
root, auxvv *fastjson.Value
|
||||
vv []*fastjson.Value
|
||||
v string
|
||||
obj *fastjson.Object
|
||||
)
|
||||
|
||||
if root, err = fastjson.ParseBytes(*rawJson); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if root.Type() == fastjson.TypeNull {
|
||||
// ignore NULL
|
||||
return
|
||||
}
|
||||
|
||||
if root.Type() != fastjson.TypeObject {
|
||||
return errors.InvalidData("expecting valid JSON object for record-values")
|
||||
}
|
||||
|
||||
if obj, err = root.Object(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, attr := range c.attributes {
|
||||
auxvv = obj.Get(attr.Ident)
|
||||
if auxvv == nil {
|
||||
// no values for the attribute
|
||||
continue
|
||||
}
|
||||
|
||||
if auxvv.Type() == fastjson.TypeNull {
|
||||
// ignore NULL
|
||||
return
|
||||
}
|
||||
|
||||
if auxvv.Type() != fastjson.TypeArray {
|
||||
return errors.InvalidData("expecting valid JSON array for record-values at key %s, got %s", attr.Ident, root.Type())
|
||||
}
|
||||
|
||||
if vv, err = auxvv.Array(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for pos, val := range vv {
|
||||
switch val.Type() {
|
||||
case fastjson.TypeString:
|
||||
v = string(val.GetStringBytes())
|
||||
default:
|
||||
v = val.String()
|
||||
}
|
||||
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(v) {
|
||||
v = "1"
|
||||
} else {
|
||||
v = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.SetValue(attr.Ident, uint(pos), v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) DecodeOld(raw any, r dal.ValueSetter) (err error) {
|
||||
rawJson, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return fmt.Errorf("incompatible input value type (%T), expecting *sql.RawBytes", raw)
|
||||
}
|
||||
|
||||
buf := make(map[string][]any)
|
||||
if err = json.Unmarshal(*rawJson, &buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo this is too naive
|
||||
for name, vv := range buf {
|
||||
var attr *dal.Attribute
|
||||
for a := range c.attributes {
|
||||
if c.attributes[a].Ident != name {
|
||||
continue
|
||||
}
|
||||
|
||||
attr = c.attributes[a]
|
||||
}
|
||||
|
||||
if attr == nil {
|
||||
// unrecognized value in the json doc
|
||||
continue
|
||||
}
|
||||
|
||||
for pos, v := range vv {
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(v) {
|
||||
v = "1"
|
||||
} else {
|
||||
v = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.SetValue(name, uint(pos), v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
attr = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
66
server/store/adapters/rdbms/drivers/columns_test.go
Normal file
66
server/store/adapters/rdbms/drivers/columns_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_SimpleJsonDocColumn_Decode(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
name string
|
||||
json string
|
||||
rvs types.RecordValueSet
|
||||
attr []*dal.Attribute
|
||||
}{
|
||||
{
|
||||
name: "empty object",
|
||||
json: `{}`,
|
||||
rvs: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
json: ``,
|
||||
rvs: nil,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
json: `{"foo":["bar"],"num":[11,"22"],"bool":[true, false]}`,
|
||||
rvs: types.RecordValueSet{
|
||||
{RecordID: 0, Name: "foo", Value: "bar"},
|
||||
{RecordID: 0, Name: "num", Value: "11"},
|
||||
{RecordID: 0, Name: "num", Value: "22", Place: 1},
|
||||
{RecordID: 0, Name: "bool", Value: "1"},
|
||||
{RecordID: 0, Name: "bool", Value: "", Place: 1},
|
||||
},
|
||||
|
||||
attr: []*dal.Attribute{
|
||||
{Ident: "foo"},
|
||||
{Ident: "num", MultiValue: true},
|
||||
{Ident: "bool", MultiValue: true, Type: &dal.TypeBoolean{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
var (
|
||||
sjdc = &SimpleJsonDocColumn{attributes: c.attr}
|
||||
raw = sql.RawBytes(c.json)
|
||||
rec = &types.Record{}
|
||||
)
|
||||
|
||||
req.NoError(sjdc.Decode(&raw, rec))
|
||||
req.Equal(c.rvs.String(), rec.Values.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
216
server/store/adapters/rdbms/drivers/dialect.go
Normal file
216
server/store/adapters/rdbms/drivers/dialect.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
Nuances struct {
|
||||
// HavingClauseMustUseAlias
|
||||
// For example, Postgres and SQLite require
|
||||
// use of aliases inside HAVING clause and
|
||||
// MySQL does not.
|
||||
HavingClauseMustUseAlias bool
|
||||
|
||||
// TwoStepUpsert allows support for databases which don't have an upsert
|
||||
// or we simply couldn't figure out how to make work.
|
||||
//
|
||||
// TwoStepUpsert uses the context from the update statement to figure out
|
||||
// if it needs to do an insert.
|
||||
TwoStepUpsert bool
|
||||
|
||||
// @todo change this around; temporary fix as not sure how I'd rewrite
|
||||
ExpandedJsonColumnSelector func(ident string) exp.Expression
|
||||
}
|
||||
|
||||
Dialect interface {
|
||||
// Nuances returns dialect nuances
|
||||
// subtle differences between RDBMS implementations that
|
||||
// should be handled on common code
|
||||
Nuances() Nuances
|
||||
|
||||
// GOQU returns goqu's dialect wrapper struct
|
||||
GOQU() goqu.DialectWrapper
|
||||
|
||||
DialectOptions() *sqlgen.SQLDialectOptions
|
||||
|
||||
JsonQuote(exp.Expression) exp.Expression
|
||||
|
||||
// JsonExtract returns expression that returns a value from inside JSON document
|
||||
//
|
||||
// Use this when you want use JSON encoded value
|
||||
JsonExtract(exp.Expression, ...any) (exp.Expression, error)
|
||||
|
||||
// JsonExtractUnquote returns expression that returns a value from inside JSON document:
|
||||
//
|
||||
// Use this when you want to use unencoded value!
|
||||
JsonExtractUnquote(exp.Expression, ...any) (exp.Expression, error)
|
||||
|
||||
// JsonArrayContains generates expression JSON array containment check expression
|
||||
//
|
||||
// Literal values need to be JSON docs!
|
||||
//
|
||||
// @todo recheck if we really need JsonArrayContains on Dialect interface
|
||||
JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error)
|
||||
|
||||
// AttributeCast prepares complex SQL expression that verifies
|
||||
// arbitrary string value in the db and casts it to b used in
|
||||
// comparison or soring expression
|
||||
AttributeCast(*dal.Attribute, exp.Expression) (exp.Expression, error)
|
||||
|
||||
AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error)
|
||||
|
||||
// TableCodec returns table codec (encodes & decodes data to/from db table)
|
||||
TableCodec(*dal.Model) TableCodec
|
||||
|
||||
// TypeWrap returns driver's type implementation for a particular attribute type
|
||||
TypeWrap(dal.Type) Type
|
||||
|
||||
QuoteIdent(string) string
|
||||
|
||||
AggregateBase(t TableCodec, gb []dal.AggregateAttr, out []dal.AggregateAttr) *goqu.SelectDataset
|
||||
|
||||
// AttributeToColumn converts attribute to column defunition
|
||||
AttributeToColumn(*dal.Attribute) (*ddl.Column, error)
|
||||
ColumnFits(base, assert *ddl.Column) bool
|
||||
|
||||
// ExprHandler returns driver specific expression handling
|
||||
ExprHandler(*ql.ASTNode, ...exp.Expression) (exp.Expression, error)
|
||||
|
||||
// ValHandler returns driver specific value expression handling
|
||||
ValHandler(*ql.ASTNode) (exp.Expression, error)
|
||||
|
||||
// OrderedExpression returns compatible expression for ordering
|
||||
//
|
||||
// Database understand order modifiers differently. For example, MySQL does not know
|
||||
// about NULLS FIRST/LAST. Drivers should gracefully handle this.
|
||||
OrderedExpression(exp.Expression, exp.SortDirection, exp.NullSortType) exp.OrderedExpression
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goqu.SetDefaultPrepared(true)
|
||||
}
|
||||
|
||||
// @note copied to data_definer_test to avoid import cycle; if modified, fixup both parts
|
||||
func IndexFieldModifiers(attr *dal.Attribute, quoteIdent func(i string) string, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
var (
|
||||
modifier string
|
||||
out = quoteIdent(attr.StoreIdent())
|
||||
)
|
||||
|
||||
for _, m := range mm {
|
||||
switch m {
|
||||
case dal.IndexFieldModifierLower:
|
||||
modifier = "LOWER"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown index field modifier: %s", m)
|
||||
}
|
||||
|
||||
out = fmt.Sprintf("%s(%s)", modifier, out)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func OpHandlerIn(d Dialect, n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
return opHandlerIn(d, n, false, args...)
|
||||
}
|
||||
|
||||
func OpHandlerNotIn(d Dialect, n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
return opHandlerIn(d, n, true, args...)
|
||||
}
|
||||
|
||||
func opHandlerIn(d Dialect, n *ql.ASTNode, negate bool, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
if len(n.Args) == 2 && n.Args[1] != nil && n.Args[1].Meta["dal.Attribute"] != nil && n.Args[1].Meta["dal.Attribute"].(*dal.Attribute).MultiValue {
|
||||
// if right-side argument is multi-value attribute,
|
||||
// then we need to adjust the arguments a bit:
|
||||
// 1) left side, if it is a value, is encoded as JSON
|
||||
// 2) if ref we access JSON encoded value
|
||||
//
|
||||
// right side, access JSON encoded array of values.
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
for a := range n.Args {
|
||||
left := a == 0
|
||||
|
||||
switch {
|
||||
case n.Args[a].Meta != nil && n.Args[a].Meta["dal.Attribute"] != nil:
|
||||
// symbol, ident probably...
|
||||
var (
|
||||
attr = n.Args[a].Meta["dal.Attribute"].(*dal.Attribute)
|
||||
model = n.Args[a].Meta["dal.Model"].(*dal.Model)
|
||||
storeIdent = exp.NewIdentifierExpression(
|
||||
"",
|
||||
model.Ident,
|
||||
attr.StoreIdent(),
|
||||
)
|
||||
|
||||
_, isJSON = attr.Store.(*dal.CodecRecordValueSetJSON)
|
||||
)
|
||||
|
||||
if attr.MultiValue {
|
||||
if left {
|
||||
return nil, fmt.Errorf("multi-value attribute %s cannot be used as left-side argument of IN operator", attr.Ident)
|
||||
}
|
||||
|
||||
args[a], err = d.JsonExtract(storeIdent, attr.Ident)
|
||||
} else {
|
||||
if !left {
|
||||
return nil, fmt.Errorf("single-value attribute %s cannot be used as right-side argument of IN operator", attr.Ident)
|
||||
}
|
||||
|
||||
if isJSON {
|
||||
args[a], err = d.JsonExtract(storeIdent, attr.Ident, 0)
|
||||
} else if attr.Type.Type() == dal.AttributeTypeBoolean {
|
||||
// SQLite converts boolean to integer but JSON stores boolean as boolean
|
||||
args[a] = exp.NewCaseExpression().
|
||||
When(exp.NewBooleanExpression(exp.EqOp, args[a], LiteralTRUE), exp.NewLiteralExpression(`'true'`)).
|
||||
When(exp.NewBooleanExpression(exp.EqOp, args[a], LiteralFALSE), exp.NewLiteralExpression(`'false'`)).
|
||||
Else(LiteralNULL)
|
||||
} else {
|
||||
args[a] = d.JsonQuote(args[a])
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case a == 0 && n.Args[a].Value != nil:
|
||||
// for 1st arg only, when value
|
||||
var jsonDoc []byte
|
||||
jsonDoc, err = json.Marshal(n.Args[a].Value.V.Get())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// encode it as json
|
||||
args[a] = exp.NewLiteralExpression("?", string(jsonDoc))
|
||||
}
|
||||
}
|
||||
|
||||
expr, err = d.JsonArrayContains(args[0], args[1])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if negate {
|
||||
expr = exp.NewLiteralExpression("NOT ?", expr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported IN operator on a single value field")
|
||||
}
|
||||
46
server/store/adapters/rdbms/drivers/id.go
Normal file
46
server/store/adapters/rdbms/drivers/id.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// nullUint64 represents an uint64 that may be null.
|
||||
// nullUint64 implements the Scanner interface so
|
||||
// it can be used as a scan dåestination, similar to NullString.
|
||||
type ID struct {
|
||||
ID uint64
|
||||
Valid bool // Valid is true if Uint64 is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (n *ID) Scan(value any) (err error) {
|
||||
if value == nil {
|
||||
n.ID, n.Valid = 0, false
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOT NULL => Valid!
|
||||
n.Valid = true
|
||||
if b, is := value.([]byte); is {
|
||||
n.ID, err = strconv.ParseUint(string(b), 10, 64)
|
||||
} else {
|
||||
n.ID, err = cast.ToUint64E(value)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (n ID) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return n.ID, nil
|
||||
}
|
||||
|
||||
func (n ID) String() string {
|
||||
return strconv.FormatUint(n.ID, 10)
|
||||
}
|
||||
125
server/store/adapters/rdbms/drivers/mssql/connect.go
Normal file
125
server/store/adapters/rdbms/drivers/mssql/connect.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
mssql "github.com/denisenkom/go-mssqldb"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "sqlserver"
|
||||
|
||||
// debug schema with verbose logging
|
||||
// @todo this won't connect yet
|
||||
// debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// store.Register(Connect, SCHEMA, debugSchema)
|
||||
// sql.Register(debugSchema, sqlmw.Driver(new(mssql.Driver), instrumentation.Debug()))
|
||||
store.Register(Connect, SCHEMA)
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db, err := connectBase(ctx, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
// TxRetryErrHandler: txRetryErrHandler,
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func connectBase(ctx context.Context, cfg *rdbms.ConnConfig) (db *sqlx.DB, err error) {
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// See https://stackoverflow.com/questions/2901453/sql-standard-to-escape-column-names
|
||||
if _, err = db.ExecContext(ctx, `SET QUOTED_IDENTIFIER ON`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
//
|
||||
// If param is missing it sets it to default but returns
|
||||
// error in case of incorrect param value
|
||||
//
|
||||
// See https://github.com/go-sql-driver/mysql for available dsn params
|
||||
//
|
||||
func NewConfig(dsn string) (c *rdbms.ConnConfig, err error) {
|
||||
const (
|
||||
validScheme = "sqlserver"
|
||||
)
|
||||
var (
|
||||
scheme string
|
||||
u *url.URL
|
||||
)
|
||||
c = &rdbms.ConnConfig{DriverName: scheme}
|
||||
|
||||
if u, err = url.Parse(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "sqlserver") {
|
||||
scheme = u.Scheme
|
||||
u.Scheme = validScheme
|
||||
}
|
||||
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: scheme,
|
||||
DataSourceName: u.String(),
|
||||
DBName: strings.Trim(u.Path, "/"),
|
||||
MaskedDSN: u.Redacted(),
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(mssql.Error); ok {
|
||||
// https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/database-engine-events-and-errors?view=sql-server-ver16
|
||||
switch implErr.Number {
|
||||
case 987, 2627: // Can't write, because of unique constraint, to table '%s'
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
32
server/store/adapters/rdbms/drivers/mssql/dal.go
Normal file
32
server/store/adapters/rdbms/drivers/mssql/dal.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
dal.RegisterConnector(dalConnector, SCHEMA)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
db, err := connectBase(ctx, cfg)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
198
server/store/adapters/rdbms/drivers/mssql/data_definer.go
Normal file
198
server/store/adapters/rdbms/drivers/mssql/data_definer.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *mssqlDialect
|
||||
}
|
||||
|
||||
// Custom ddl commands tweaked to SQL server specifics.
|
||||
// It might be cleaner to solve this with goqu or some custom string templates
|
||||
// but this will do for now.
|
||||
addColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Column *ddl.Column
|
||||
}
|
||||
|
||||
renameColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
reTypeColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Column string
|
||||
Type *ddl.ColumnType
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (tbl *ddl.Table, err error) {
|
||||
tbl, err = ddl.ConvertModel(m, dd.d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We'll solve conditional indexes on the app level
|
||||
// @todo check if we can use what they provide; the code to replace upsert
|
||||
// wasn't working for me.
|
||||
//
|
||||
// We need to prevent these indexes from adding
|
||||
//
|
||||
// loop through indexes and remove all with predicate
|
||||
|
||||
for i := len(tbl.Indexes) - 1; i >= 0; i-- {
|
||||
if tbl.Indexes[i].Predicate != "" {
|
||||
tbl.Indexes = append(tbl.Indexes[:i], tbl.Indexes[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &addColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &renameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &reTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *addColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
sql = fmt.Sprintf(
|
||||
`ALTER TABLE %s ADD %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column.Ident),
|
||||
c.Column.Type.Name,
|
||||
)
|
||||
|
||||
if !c.Column.Type.Null {
|
||||
sql += " NOT NULL"
|
||||
}
|
||||
|
||||
if len(c.Column.Default) > 0 {
|
||||
// @todo right now we can (and need to) trust that default
|
||||
// values are unharmful!
|
||||
sql += " DEFAULT " + c.Column.Default
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *renameColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`EXEC sp_RENAME '%s.%s' , '%s', 'COLUMN'`,
|
||||
c.Table,
|
||||
c.Old,
|
||||
c.New,
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func (c *reTypeColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s ALTER COLUMN %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
c.Type.Name,
|
||||
), nil, nil
|
||||
}
|
||||
399
server/store/adapters/rdbms/drivers/mssql/dialect.go
Normal file
399
server/store/adapters/rdbms/drivers/mssql/dialect.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/cast2"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/sqlserver"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
mssqlDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &mssqlDialect{}
|
||||
|
||||
dialect = &mssqlDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("sqlserver")
|
||||
goquDialectOptions *sqlgen.SQLDialectOptions
|
||||
quoteIdent = string(sqlserver.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
TwoStepUpsert: true,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goquDialectOptions = sqlserver.DialectOptions()
|
||||
|
||||
// MSSQL doesn't support the classic boolean constants but we still need to
|
||||
// boolean expressions.
|
||||
// Disallowing boolean datatype on goqu level is too strict and it prevents most
|
||||
// of the queries to work.
|
||||
goquDialectOptions.BooleanDataTypeSupported = true
|
||||
|
||||
// Use 1/0 as an alternative to booleans
|
||||
goquDialectOptions.True = []byte("1")
|
||||
goquDialectOptions.False = []byte("0")
|
||||
goquDialectOptions.TruncateClause = []byte("TRUNCATE TABLE")
|
||||
|
||||
// d.CastFragment = []byte("TRY_CONVERT")
|
||||
|
||||
// Overriding vanila dialect
|
||||
goqu.RegisterDialect("sqlserver", goquDialectOptions)
|
||||
}
|
||||
|
||||
func Dialect() *mssqlDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (mssqlDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d mssqlDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
// @todo as is, aggregation is not offloaded to mssql
|
||||
panic("not implemented")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mssqlDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (mssqlDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (mssqlDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d mssqlDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"JSON_VALUE",
|
||||
expr,
|
||||
exp.NewLiteralExpression("'$[0]'"),
|
||||
)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonExtract(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewLiteralExpression("CASE WHEN ISJSON(?) = 1 THEN ? ELSE NULL END", jsonDoc, exp.NewSQLFunctionExpression("JSON_QUERY", jsonDoc, path)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonExtractUnquote(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewLiteralExpression("CASE WHEN ISJSON(?) = 1 THEN ? ELSE NULL END", jsonDoc, exp.NewSQLFunctionExpression("JSON_VALUE", jsonDoc, path)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares sqlserver compatible comparison of value (or ident) and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, JSON_EXTRACT(needle, '$.f3'), '$.f2')
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, '"needle"', '$.f2')
|
||||
//
|
||||
// This approach is not optimal, but it is the only way to make it work
|
||||
func (d mssqlDialect) JsonArrayContains(needle, haystack exp.Expression) (_ exp.Expression, err error) {
|
||||
// @todo untested
|
||||
return exp.NewLiteralExpression(
|
||||
"? IN ?",
|
||||
needle,
|
||||
exp.NewSQLFunctionExpression("OPENJSON", haystack),
|
||||
), nil
|
||||
// return exp.NewSQLFunctionExpression(
|
||||
|
||||
// "JSON_CONTAINS",
|
||||
// haystack, needle), nil
|
||||
|
||||
// return exp.NewSQLFunctionExpression("JSON_CONTAINS", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d mssqlDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
return &drivers.TypeTimestamp{&dal.TypeTimestamp{
|
||||
Nullable: c.Nullable,
|
||||
|
||||
// sqlserver does not support timezone
|
||||
Timezone: false,
|
||||
|
||||
// sqlserver does not support precision
|
||||
Precision: 0,
|
||||
}}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
// AttributeCast for sqlserver
|
||||
//
|
||||
// https://dev.sqlserver.com/doc/refman/8.0/en/cast-functions.html#function_cast
|
||||
func (mssqlDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (expr exp.Expression, err error) {
|
||||
|
||||
switch attr.Type.(type) {
|
||||
|
||||
case *dal.TypeText:
|
||||
expr = exp.NewCastExpression(val, "VARCHAR(MAX)")
|
||||
|
||||
default:
|
||||
return attributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (mssqlDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (mssqlDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "BIGINT"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "DATETIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "DECIMAL"
|
||||
// @todo precision, scale?
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "VARBINARY(MAX)"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BIT"
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (mssqlDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"datetime": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"time": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"date": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"decimal": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"text": {
|
||||
"nvarchar": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"varchar": {
|
||||
"nvarchar": true,
|
||||
},
|
||||
"nvarchar": {
|
||||
"varchar": true,
|
||||
},
|
||||
"varbinary": {},
|
||||
"bit": {},
|
||||
"char": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// @note mssql represents max as -1 so we're just going with this
|
||||
if assertMeta[0] == "max" {
|
||||
assertMeta[0] = "-1"
|
||||
}
|
||||
if targetMeta[0] == "max" {
|
||||
targetMeta[0] = "-1"
|
||||
}
|
||||
|
||||
// Check varchar size
|
||||
for i := len(assertMeta); i < 1; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
assertA := cast.ToInt(assertMeta[0])
|
||||
|
||||
for i := len(targetMeta); i < 1; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
targetA := cast.ToInt(targetMeta[0])
|
||||
|
||||
// -1 means no limit so it can fit any length
|
||||
// - if target is max, any varchar fits
|
||||
if targetA == -1 {
|
||||
return baseMatch
|
||||
}
|
||||
// - if assert is max, only max fits
|
||||
if assertA == -1 {
|
||||
return baseMatch && targetA == -1
|
||||
}
|
||||
|
||||
return baseMatch && assertA <= targetA
|
||||
|
||||
case assertName == "decimal" && targetName == "decimal":
|
||||
// Check decimal size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
// @todo untested
|
||||
func (d mssqlDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return exp.NewLiteralExpression("?"+strings.Repeat(" || ?", len(args)-1), cast2.Anys(args...)...), nil
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
return ql.DefaultValueHandler(n)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, _ exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
|
||||
// attributeCast uses mssql's TRY_CONVERT to avoid the need for regex value validation
|
||||
// since mssql doesn't support regexes, nor can we use the extended LIKE.
|
||||
func attributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(BIGINT,?)", val), nil
|
||||
|
||||
case *dal.TypeNumber:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(NUMERIC,?)", val), nil
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(DATETIMEOFFSET,?)", val), nil
|
||||
|
||||
case *dal.TypeDate:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(DATE,?)", val), nil
|
||||
|
||||
case *dal.TypeTime:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(TIME,?)", val), nil
|
||||
|
||||
case *dal.
|
||||
TypeBoolean:
|
||||
return exp.NewLiteralExpression("CASE ? WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE TRY_CONVERT(BIT,?) END", val, val), nil
|
||||
|
||||
default:
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
191
server/store/adapters/rdbms/drivers/mssql/dialect_test.go
Normal file
191
server/store/adapters/rdbms/drivers/mssql/dialect_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (varchar)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(max)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(max)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "decimal fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "decimal doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(2,3)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(84)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar(max) fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(max)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar(max) doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(max)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetime exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetime doesn't fit time",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time doesn't fit datetime",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetimeoffset exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetimeoffset"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetimeoffset"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
d := mssqlDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
268
server/store/adapters/rdbms/drivers/mssql/information_schema.go
Normal file
268
server/store/adapters/rdbms/drivers/mssql/information_schema.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect(dbname).Where(
|
||||
// We don't need to check for DB since the values are already scoped there
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect(dbname string) *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"IS_NULLABLE",
|
||||
"DATA_TYPE",
|
||||
"CHARACTER_MAXIMUM_LENGTH",
|
||||
"NUMERIC_SCALE",
|
||||
"DATETIME_PRECISION",
|
||||
).
|
||||
// @note this goqu.I is a cheat; try to figure out if we have something nicer available (same applies to lower code)
|
||||
From(goqu.I(fmt.Sprintf("%s.INFORMATION_SCHEMA.COLUMNS", dbname))).
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ORDINAL_POSITION"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"TABLE_NAME"`
|
||||
Column string `db:"COLUMN_NAME"`
|
||||
IsNullable string `db:"IS_NULLABLE"`
|
||||
Type string `db:"DATA_TYPE"`
|
||||
// We'll use these two to get numeric's precision and scale
|
||||
Precision any `db:"DATETIME_PRECISION"`
|
||||
Scale any `db:"NUMERIC_SCALE"`
|
||||
// We'll use this one to get varchar length
|
||||
MaxLength any `db:"CHARACTER_MAXIMUM_LENGTH"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
if v.Type == "decimal" {
|
||||
var (
|
||||
p, s int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.Precision) {
|
||||
p = cast.ToInt(v.Precision)
|
||||
}
|
||||
if !reflect2.IsNil(v.Scale) {
|
||||
s = cast.ToInt(v.Scale)
|
||||
}
|
||||
|
||||
if p+s > 0 {
|
||||
v.Type = fmt.Sprintf("%s(%d,%d)", v.Type, p, s)
|
||||
}
|
||||
}
|
||||
|
||||
if v.Type == "varchar" {
|
||||
var (
|
||||
l int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.MaxLength) {
|
||||
l = cast.ToInt(v.MaxLength)
|
||||
}
|
||||
|
||||
v.Type = fmt.Sprintf("%s(%d)", v.Type, l)
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: v.Type,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, dbname string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndexOnly = i.indexSelect(dbname).Where(
|
||||
// We don't need to check TABLE_SCHEMA because it's all scoped under there already
|
||||
exp.ParseIdentifier("si.name").Eq(index),
|
||||
exp.ParseIdentifier("t.name").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndexOnly); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect(dbname string) *goqu.SelectDataset {
|
||||
x := dialect.GOQU().Select(
|
||||
goqu.I("si.name").As("INDEX_NAME"),
|
||||
goqu.I("t.name").As("TABLE_NAME"),
|
||||
goqu.I("col.name").As("COLUMN_NAME"),
|
||||
goqu.I("si.type_desc").As("INDEX_TYPE"),
|
||||
|
||||
// @todo
|
||||
exp.NewAliasExpression(exp.NewLiteralExpression("''"), "EXPRESSION"),
|
||||
exp.NewAliasExpression(exp.NewLiteralExpression("''"), "INDEX_COMMENT"),
|
||||
).
|
||||
From(
|
||||
goqu.From(goqu.I(fmt.Sprintf("%s.sys.indexes", dbname))).As("si"),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.index_columns", dbname)).As("ic"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id = ic.object_id and si.index_id = ic.index_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.columns", dbname)).As("col"),
|
||||
goqu.On(exp.NewLiteralExpression("ic.object_id = col.object_id and ic.column_id = col.column_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.tables", dbname)).As("t"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id = t.object_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.objects", dbname)).As("so"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id=so.object_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.schemas", dbname)).As("sc"),
|
||||
goqu.On(exp.NewLiteralExpression("so.schema_id=sc.schema_id")),
|
||||
)
|
||||
|
||||
return x
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Name string `db:"INDEX_NAME"`
|
||||
Table string `db:"TABLE_NAME"`
|
||||
NonUnique bool `db:"NON_UNIQUE"`
|
||||
Type string `db:"INDEX_TYPE"`
|
||||
|
||||
// @todo there's also a "COMMENT" column?
|
||||
Comment string `db:"INDEX_COMMENT"`
|
||||
|
||||
Expression sql.NullString `db:"EXPRESSION"`
|
||||
ColumnName sql.NullString `db:"COLUMN_NAME"`
|
||||
ColumnSubPart sql.NullInt32 `db:"SUB_PART"`
|
||||
ColumnCollation sql.NullString `db:"COLLATION"`
|
||||
|
||||
// stats
|
||||
ColumnStatsCardinality int64 `db:"CARDINALITY"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, 0, 10)
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
if at, has = n2p[a.Name]; !has {
|
||||
out = append(out, &ddl.Index{
|
||||
Ident: a.Name,
|
||||
TableIdent: a.Table,
|
||||
Type: a.Type,
|
||||
Comment: a.Comment,
|
||||
Fields: make([]*ddl.IndexField, 0),
|
||||
Unique: !a.NonUnique,
|
||||
Predicate: "",
|
||||
Meta: nil,
|
||||
})
|
||||
n2p[a.Name] = p
|
||||
at = p
|
||||
}
|
||||
|
||||
col := &ddl.IndexField{
|
||||
Length: int(a.ColumnSubPart.Int32),
|
||||
Statistics: &ddl.IndexFieldStatistics{
|
||||
Cardinality: a.ColumnStatsCardinality,
|
||||
},
|
||||
}
|
||||
|
||||
switch a.ColumnCollation.String {
|
||||
case "A":
|
||||
col.Sort = dal.IndexFieldSortAsc
|
||||
case "D":
|
||||
col.Sort = dal.IndexFieldSortDesc
|
||||
}
|
||||
|
||||
if a.Expression.Valid {
|
||||
col.Expression = a.Expression.String
|
||||
} else {
|
||||
col.Column = a.ColumnName.String
|
||||
}
|
||||
|
||||
out[at].Fields = append(out[at].Fields, col)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
41
server/store/adapters/rdbms/drivers/mssql/json.go
Normal file
41
server/store/adapters/rdbms/drivers/mssql/json.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
func jsonPathExpr(pp ...any) (exp.LiteralExpression, error) {
|
||||
if path, err := jsonPath(pp...); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return exp.NewLiteralExpression("'" + path + "'"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/mssql/json_test.go
Normal file
42
server/store/adapters/rdbms/drivers/mssql/json_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
152
server/store/adapters/rdbms/drivers/mssql/ql.go
Normal file
152
server/store/adapters/rdbms/drivers/mssql/ql.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"interval": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_add": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_sub": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_format": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"FORMAT",
|
||||
args[0],
|
||||
translateDateFormatParam(args[1]),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("GETDATE")
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(date,?)", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("DATEPART(QUARTER, ?)", args[0])
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("YEAR", args[0])
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("MONTH", args[0])
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DAY", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(DATETIME, ?)", args[0])
|
||||
},
|
||||
},
|
||||
"time": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(TIME, ?)", args[0])
|
||||
},
|
||||
},
|
||||
}.ExprHandlers()
|
||||
|
||||
// @todo consider
|
||||
// supportedSubstitutions = map[string]bool{
|
||||
// "d": true,
|
||||
// "H": true,
|
||||
// "j": true,
|
||||
// "m": true,
|
||||
// "M": true,
|
||||
// "S": true,
|
||||
// "w": true,
|
||||
// "W": true,
|
||||
// "Y": true,
|
||||
// "%": true,
|
||||
// }
|
||||
)
|
||||
|
||||
func translateDateFormatParam(e interface{}) interface{} {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e
|
||||
}
|
||||
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
return dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func dateFormatReplacer(s string) string {
|
||||
return strings.NewReplacer(
|
||||
// @todo Doing ...%dT%H... (for iso timestamp) pgsql doesn't format it correctly
|
||||
// so I'm covering this edge case.
|
||||
// We should fix this properly when we redo record storage.
|
||||
|
||||
// - this is day of month from 01-31
|
||||
`%d`, `dd`,
|
||||
// - this is the month number from 01-12 !! %m is from 0
|
||||
`%c`, `MM`,
|
||||
// - this is the month number from 01-12 !! %m is from 0
|
||||
`%m`, `MM`,
|
||||
|
||||
// - month name abbreviated
|
||||
`%b`, `MMM`,
|
||||
// - this is the month spelled out
|
||||
`%M`, `MMMM`,
|
||||
|
||||
// - this is the year with two digits
|
||||
`%y`, `yy`,
|
||||
// - this is the year with four digits
|
||||
`%Y`, `yyyy`,
|
||||
|
||||
// - this is the hour from 01-12
|
||||
`%l`, `hh`,
|
||||
// - this is the hour from 00-23
|
||||
`%k`, `HH`,
|
||||
|
||||
// - this is the minute from 00-59
|
||||
`%i`, `mm`,
|
||||
// - this is the second from 00-59
|
||||
`%s`, `ss`,
|
||||
// - this is the second from 00-59
|
||||
`%S`, `ss`,
|
||||
|
||||
// - this shows either AM or PM
|
||||
`%r`, `tt`,
|
||||
).Replace(s)
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/mssql/types.go
Normal file
42
server/store/adapters/rdbms/drivers/mssql/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, 0)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
199
server/store/adapters/rdbms/drivers/mysql/connect.go
Normal file
199
server/store/adapters/rdbms/drivers/mysql/connect.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "mysql"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
store.Register(Connect, SCHEMA, debugSchema)
|
||||
sql.Register(debugSchema, sqlmw.Driver(new(mysql.MySQLDriver), instrumentation.Debug()))
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db, err := connectBase(ctx, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
TxRetryErrHandler: txRetryErrHandler,
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func connectBase(ctx context.Context, cfg *rdbms.ConnConfig) (db *sqlx.DB, err error) {
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// See https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi for details
|
||||
if _, err = db.ExecContext(ctx, `SET SESSION sql_mode = 'ANSI'`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
//
|
||||
// If param is missing it sets it to default but returns
|
||||
// error in case of incorrect param value
|
||||
//
|
||||
// See https://github.com/go-sql-driver/mysql for available dsn params
|
||||
//
|
||||
func NewConfig(in string) (*rdbms.ConnConfig, error) {
|
||||
const (
|
||||
schemeDel = "://"
|
||||
validScheme = "mysql"
|
||||
)
|
||||
|
||||
var (
|
||||
endOfSchema = strings.Index(in, schemeDel)
|
||||
|
||||
c = &rdbms.ConnConfig{}
|
||||
)
|
||||
|
||||
if endOfSchema > 0 && (in[:endOfSchema] == validScheme || strings.HasPrefix(in[:endOfSchema], validScheme+"+")) {
|
||||
c.DriverName = in[:endOfSchema]
|
||||
c.DataSourceName = in[endOfSchema+len(schemeDel):]
|
||||
} else {
|
||||
return nil, fmt.Errorf("expecting valid schema (mysql://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
if pdsn, err := mysql.ParseDSN(c.DataSourceName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
// Ensure driver parses time
|
||||
pdsn.ParseTime = true
|
||||
|
||||
if pdsn.Collation == "" {
|
||||
pdsn.Collation = "utf8mb4_general_ci"
|
||||
}
|
||||
|
||||
if pdsn.Params == nil {
|
||||
pdsn.Params = make(map[string]string)
|
||||
}
|
||||
|
||||
if pdsn.Params["charset"] == "" {
|
||||
pdsn.Params["charset"] = "utf8mb4"
|
||||
}
|
||||
|
||||
c.DataSourceName = pdsn.FormatDSN()
|
||||
c.DBName = pdsn.DBName
|
||||
c.MaskedDSN = maskDSN(pdsn)
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Connection setup
|
||||
func connSetup(ctx context.Context, db sqlx.ExecerContext) (err error) {
|
||||
// See https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi for details
|
||||
if _, err = db.ExecContext(ctx, `SET SESSION sql_mode = 'ANSI'`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func maskDSN(myCnf *mysql.Config) string {
|
||||
var (
|
||||
maskedDSN = myCnf.Clone()
|
||||
)
|
||||
|
||||
if maskedDSN.Passwd != "" {
|
||||
// following the same logic as url.Redacted (used for postgresql)
|
||||
maskedDSN.Passwd = "xxxxx"
|
||||
}
|
||||
|
||||
return myCnf.FormatDSN()
|
||||
}
|
||||
|
||||
func txRetryErrHandler(try int, err error) bool {
|
||||
var (
|
||||
mysqlErr *mysql.MySQLError
|
||||
ok bool
|
||||
)
|
||||
|
||||
// unwrap errors until we find one comming from MySQL
|
||||
for errors.Unwrap(err) != nil {
|
||||
err = errors.Unwrap(err)
|
||||
|
||||
mysqlErr, ok = err.(*mysql.MySQLError)
|
||||
if !ok || mysqlErr == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if mysqlErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch mysqlErr.Number {
|
||||
case
|
||||
1205, // lock within transaction
|
||||
1206, // total number of locks exceeded the lock table size
|
||||
1208, // not allowed while thread is holding global read lock
|
||||
1213: // deadlock found
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(*mysql.MySQLError); ok {
|
||||
// https://www.fromdual.com/de/mysql-error-codes-and-messages
|
||||
switch implErr.Number {
|
||||
case 1062: // Can't write, because of unique constraint, to table '%s'
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
27
server/store/adapters/rdbms/drivers/mysql/connect_test.go
Normal file
27
server/store/adapters/rdbms/drivers/mysql/connect_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcDataSourceName(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
c *rdbms.ConnConfig
|
||||
err error
|
||||
)
|
||||
|
||||
c, err = NewConfig("mysql://uid:@/dbname?parseTime=true")
|
||||
req.NoError(err)
|
||||
req.Contains(c.DataSourceName, "parseTime=true")
|
||||
req.Equal(c.DBName, "dbname")
|
||||
|
||||
c, err = NewConfig("mysql+foo://uid:@/dbname")
|
||||
req.NoError(err)
|
||||
req.Contains(c.DataSourceName, "parseTime=true")
|
||||
req.Equal(c.DriverName, "mysql+foo")
|
||||
req.Equal(c.DBName, "dbname")
|
||||
}
|
||||
31
server/store/adapters/rdbms/drivers/mysql/dal.go
Normal file
31
server/store/adapters/rdbms/drivers/mysql/dal.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
db, err := connectBase(ctx, cfg)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
269
server/store/adapters/rdbms/drivers/mysql/data_definer.go
Normal file
269
server/store/adapters/rdbms/drivers/mysql/data_definer.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *mysqlDialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (tbl *ddl.Table, err error) {
|
||||
tbl, err = ddl.ConvertModel(m, dd.d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Sadly, MySQL does not support conditional indexes
|
||||
// We'll solve that on an app level.
|
||||
//
|
||||
// We need to prevent these indexes from adding
|
||||
//
|
||||
// loop through indexes and remove all with predicate
|
||||
|
||||
for i := len(tbl.Indexes) - 1; i >= 0; i-- {
|
||||
if tbl.Indexes[i].Predicate != "" {
|
||||
tbl.Indexes = append(tbl.Indexes[:i], tbl.Indexes[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.ReTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
//// TableExists checks if table exists in the MySQL database
|
||||
//func (s *schema) TableExists(ctx context.Context, db sqlx.QueryerContext, table string) (bool, error) {
|
||||
// return ddl.TableExists(ctx, db, Dialect(), table, s.dbName)
|
||||
//}
|
||||
//
|
||||
//// ColumnExists checks if column exists in the MySQL table
|
||||
//func (s *schema) ColumnExists(ctx context.Context, db sqlx.QueryerContext, column, table string) (bool, error) {
|
||||
// return ddl.ColumnExists(ctx, db, Dialect(), column, table, s.dbName)
|
||||
//}
|
||||
//
|
||||
//// CreateTable
|
||||
////
|
||||
//// MySQL does not hav CREATE-INDEX-IF-NOT-EXISTS; we need to check index existence manually
|
||||
//func (s *schema) CreateTable(ctx context.Context, db sqlx.ExtContext, t *ddl.Table) (err error) {
|
||||
//
|
||||
// tc := &ddl.CreateTable{
|
||||
// Dialect: Dialect(),
|
||||
// Table: t,
|
||||
// SuffixClause: "ENGINE=InnoDB DEFAULT CHARSET=utf8",
|
||||
// }
|
||||
//
|
||||
// if err = ddl.Exec(ctx, db, tc); err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// for _, index := range t.Indexes {
|
||||
// if index.Condition != "" {
|
||||
// // MySQL, sad little DB does not support
|
||||
// // conditional indexes
|
||||
// //
|
||||
// // We'll solve this on an application level
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// var doesIt bool
|
||||
// if doesIt, err = ddl.IndexExists(ctx, db, Dialect(), index.Name, index.Table, s.dbName); err != nil {
|
||||
// return
|
||||
// } else if doesIt {
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// ic := &ddl.CreateIndex{
|
||||
// Dialect: Dialect(),
|
||||
// Index: index,
|
||||
// OmitIfNotExistsClause: true,
|
||||
// }
|
||||
//
|
||||
// if err = ddl.Exec(ctx, db, ic); err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//func (s *schema) AddColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, cc ...*ddl.Column) (err error) {
|
||||
// var (
|
||||
// aux []any
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// for _, c := range cc {
|
||||
// // check column existence
|
||||
// if exists, err = s.ColumnExists(ctx, db, c.Name, t.Name); err != nil {
|
||||
// return
|
||||
// } else if exists {
|
||||
// // column exists
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Sadly, some column types in MySQL can not have default values
|
||||
// if c.Type.Type == ddl.ColumnTypejsonb|| c.Type.Type == ddl.ColumnTypeBinary || c.Type.Type == ddl.ColumnTypeText {
|
||||
// c.DefaultValue = ""
|
||||
// }
|
||||
//
|
||||
// aux = append(aux, &ddl.AddColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Column: c,
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, aux...)
|
||||
//}
|
||||
//
|
||||
//func (s *schema) DropColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, cc ...string) (err error) {
|
||||
// var (
|
||||
// aux []any
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// for _, c := range cc {
|
||||
// // check column existence
|
||||
// if exists, err = s.ColumnExists(ctx, db, c, t.Name); err != nil {
|
||||
// return
|
||||
// } else if !exists {
|
||||
// // column exists
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// aux = append(aux, &ddl.DropColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Column: c,
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, aux...)
|
||||
//}
|
||||
//
|
||||
//func (s *schema) RenameColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, old, new string) (err error) {
|
||||
// var (
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// if exists, err = s.ColumnExists(ctx, db, old, t.Name); err != nil || !exists {
|
||||
// // error or old column does not exist
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if exists, err = s.ColumnExists(ctx, db, new, t.Name); err != nil || exists {
|
||||
// // error or new column already exists
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, &ddl.RenameColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Old: old,
|
||||
// New: new,
|
||||
// })
|
||||
//}
|
||||
434
server/store/adapters/rdbms/drivers/mysql/dialect.go
Normal file
434
server/store/adapters/rdbms/drivers/mysql/dialect.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
mysqlDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &mysqlDialect{}
|
||||
|
||||
dialect = &mysqlDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("mysql")
|
||||
goquDialectOptions = mysql.DialectOptions()
|
||||
quoteIdent = string(mysql.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: false,
|
||||
}
|
||||
)
|
||||
|
||||
func Dialect() *mysqlDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (mysqlDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d mysqlDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
ax, err := t.AttributeExpressionQuoted(colName)
|
||||
if err != nil {
|
||||
q = q.SetError(err)
|
||||
return q
|
||||
}
|
||||
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("JSON_TABLE",
|
||||
ax,
|
||||
exp.NewLiteralExpression(fmt.Sprintf(`'$[*]' COLUMNS (%s TEXT PATH '$')`, colName)),
|
||||
).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func (mysqlDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (mysqlDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (mysqlDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d mysqlDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"JSON_EXTRACT",
|
||||
exp.NewSQLFunctionExpression("JSON_ARRAY", expr),
|
||||
exp.NewLiteralExpression("'$[0]'"),
|
||||
)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonExtract(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewSQLFunctionExpression("JSON_EXTRACT", jsonDoc, path), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonExtractUnquote(jsonDoc exp.Expression, pp ...any) (_ exp.Expression, err error) {
|
||||
if jsonDoc, err = d.JsonExtract(jsonDoc, pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewSQLFunctionExpression("JSON_UNQUOTE", jsonDoc), nil
|
||||
}
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares MySQL compatible comparison of value (or ident) and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, JSON_EXTRACT(needle, '$.f3'), '$.f2')
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, '"needle"', '$.f2')
|
||||
//
|
||||
// This approach is not optimal, but it is the only way to make it work
|
||||
func (d mysqlDialect) JsonArrayContains(needle, haystack exp.Expression) (_ exp.Expression, err error) {
|
||||
return exp.NewSQLFunctionExpression("JSON_CONTAINS", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d mysqlDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
return &drivers.TypeTimestamp{&dal.TypeTimestamp{
|
||||
Nullable: c.Nullable,
|
||||
|
||||
// mysql does not support timezone
|
||||
Timezone: false,
|
||||
|
||||
// mysql does not support precision
|
||||
Precision: 0,
|
||||
}}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
// AttributeCast for mySQL
|
||||
//
|
||||
// https://dev.mysql.com/doc/refman/8.0/en/cast-functions.html#function_cast
|
||||
func (mysqlDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.CastExpression
|
||||
)
|
||||
|
||||
switch attr.Type.(type) {
|
||||
|
||||
case *dal.TypeNumber:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckNumber, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DECIMAL(65,10)")
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckFullISO8061, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DATETIME")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
c = exp.NewCastExpression(drivers.BooleanCheck(val), "SIGNED")
|
||||
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckID, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "UNSIGNED")
|
||||
|
||||
case *dal.TypeTime:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckTimeISO8061, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIME")
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
}
|
||||
|
||||
func (mysqlDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (mysqlDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "BIGINT UNSIGNED"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT UNSIGNED"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "DATETIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "DECIMAL"
|
||||
// @todo precision, scale?
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "JSON"
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "JSON"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BLOB"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "TINYINT(1)"
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// target is the existing one
|
||||
func (mysqlDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint unsigned": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"bigint signed": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"bigint": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"datetime": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
},
|
||||
"time": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"datetime": true,
|
||||
},
|
||||
"date": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
|
||||
"datetime": true,
|
||||
},
|
||||
"decimal": {
|
||||
"double": true,
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"varchar": {
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
"char": true,
|
||||
},
|
||||
"text": {
|
||||
"varchar": true,
|
||||
"char": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"json": {},
|
||||
"blob": {},
|
||||
"tinyint": {
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
"decimal": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"char": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// Check varchar size
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0])
|
||||
|
||||
case assertName == "decimal" && targetName == "decimal":
|
||||
// Check numeric size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d mysqlDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
case "like", "nlike":
|
||||
for a := range args {
|
||||
args[a] = exp.NewLiteralExpression("LOWER(?)", args[a])
|
||||
}
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
switch v := n.Value.V.(type) {
|
||||
case *expr.Boolean:
|
||||
// value handling for boolean is different in mysql
|
||||
// this was done to support value parsing for (IS ?) statement
|
||||
if cast.ToBool(v.Get()) {
|
||||
out = drivers.LiteralTRUE
|
||||
} else {
|
||||
out = drivers.LiteralFALSE
|
||||
}
|
||||
default:
|
||||
out = exp.NewLiteralExpression("?", n.Value.V.Get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d mysqlDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, _ exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
156
server/store/adapters/rdbms/drivers/mysql/dialect_test.go
Normal file
156
server/store/adapters/rdbms/drivers/mysql/dialect_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (text)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "decimal fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "decimal doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(2,3)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(84)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "sneaking unsigned into signed",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint signed",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint unsigned",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "sneaking signed into unsigned",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint unsigned",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint signed",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
d := mysqlDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
208
server/store/adapters/rdbms/drivers/mysql/information_schema.go
Normal file
208
server/store/adapters/rdbms/drivers/mysql/information_schema.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect().Where(
|
||||
exp.ParseIdentifier("TABLE_CATALOG").Eq("def"),
|
||||
// schema == dbname in MySQL world
|
||||
exp.ParseIdentifier("TABLE_SCHEMA").Eq(dbname),
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"IS_NULLABLE",
|
||||
"COLUMN_TYPE",
|
||||
).
|
||||
From("information_schema.columns").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ORDINAL_POSITION"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"TABLE_NAME"`
|
||||
Column string `db:"COLUMN_NAME"`
|
||||
IsNullable string `db:"IS_NULLABLE"`
|
||||
Type string `db:"COLUMN_TYPE"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: v.Type,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, dbname string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndexOnly = i.indexSelect().Where(
|
||||
exp.ParseIdentifier("TABLE_SCHEMA").Eq(dbname),
|
||||
exp.ParseIdentifier("INDEX_NAME").Eq(index),
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndexOnly); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"INDEX_NAME",
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"INDEX_TYPE",
|
||||
"EXPRESSION",
|
||||
"INDEX_COMMENT",
|
||||
).
|
||||
From("information_schema.statistics").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("INDEX_NAME"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("SEQ_IN_INDEX"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Name string `db:"INDEX_NAME"`
|
||||
Table string `db:"TABLE_NAME"`
|
||||
NonUnique bool `db:"NON_UNIQUE"`
|
||||
Type string `db:"INDEX_TYPE"`
|
||||
|
||||
// @todo there's also a "COMMENT" column?
|
||||
Comment string `db:"INDEX_COMMENT"`
|
||||
|
||||
Expression sql.NullString `db:"EXPRESSION"`
|
||||
ColumnName sql.NullString `db:"COLUMN_NAME"`
|
||||
ColumnSubPart sql.NullInt32 `db:"SUB_PART"`
|
||||
ColumnCollation sql.NullString `db:"COLLATION"`
|
||||
|
||||
// stats
|
||||
ColumnStatsCardinality int64 `db:"CARDINALITY"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, 0, 10)
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
if at, has = n2p[a.Name]; !has {
|
||||
out = append(out, &ddl.Index{
|
||||
Ident: a.Name,
|
||||
TableIdent: a.Table,
|
||||
Type: a.Type,
|
||||
Comment: a.Comment,
|
||||
Fields: make([]*ddl.IndexField, 0),
|
||||
Unique: !a.NonUnique,
|
||||
Predicate: "",
|
||||
Meta: nil,
|
||||
})
|
||||
n2p[a.Name] = p
|
||||
at = p
|
||||
}
|
||||
|
||||
col := &ddl.IndexField{
|
||||
Length: int(a.ColumnSubPart.Int32),
|
||||
Statistics: &ddl.IndexFieldStatistics{
|
||||
Cardinality: a.ColumnStatsCardinality,
|
||||
},
|
||||
}
|
||||
|
||||
switch a.ColumnCollation.String {
|
||||
case "A":
|
||||
col.Sort = dal.IndexFieldSortAsc
|
||||
case "D":
|
||||
col.Sort = dal.IndexFieldSortDesc
|
||||
}
|
||||
|
||||
if a.Expression.Valid {
|
||||
col.Expression = a.Expression.String
|
||||
} else {
|
||||
col.Column = a.ColumnName.String
|
||||
}
|
||||
|
||||
out[at].Fields = append(out[at].Fields, col)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
44
server/store/adapters/rdbms/drivers/mysql/json.go
Normal file
44
server/store/adapters/rdbms/drivers/mysql/json.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MySQL honours backslash escapes inside string literals so backslashes need to
|
||||
// be escaped alongside the quotes
|
||||
var jsonPathEscaper = strings.NewReplacer(`\`, `\\`, "'", `''`)
|
||||
|
||||
func jsonPathExpr(pp ...any) (exp.LiteralExpression, error) {
|
||||
if path, err := jsonPath(pp...); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return exp.NewLiteralExpression("'" + path + "'"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(jsonPathEscaper.Replace(path))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
50
server/store/adapters/rdbms/drivers/mysql/json_test.go
Normal file
50
server/store/adapters/rdbms/drivers/mysql/json_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
{
|
||||
input: []any{`tw\o`},
|
||||
path: `$.tw\\o`,
|
||||
},
|
||||
{
|
||||
input: []any{`tw\'o`},
|
||||
path: `$.tw\\''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
server/store/adapters/rdbms/drivers/mysql/ql.go
Normal file
16
server/store/adapters/rdbms/drivers/mysql/ql.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"std": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STD", args[0])
|
||||
},
|
||||
},
|
||||
}.ExprHandlers()
|
||||
)
|
||||
57
server/store/adapters/rdbms/drivers/mysql/types.go
Normal file
57
server/store/adapters/rdbms/drivers/mysql/types.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, 0)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
//func columnTypeTranslator(ct ddl.ColumnType) string {
|
||||
// switch ct.Type {
|
||||
// case ddl.ColumnTypeIdentifier:
|
||||
// return "BIGINT UNSIGNED"
|
||||
// case ddl.ColumnTypeBinary:
|
||||
// return "BLOB"
|
||||
// case ddl.ColumnTypeTimestamp:
|
||||
// return "DATETIME"
|
||||
// case ddl.ColumnTypeBoolean:
|
||||
// return "TINYINT(1)"
|
||||
// default:
|
||||
// return ddl.ColumnTypeTranslator(ct)
|
||||
// }
|
||||
//}
|
||||
111
server/store/adapters/rdbms/drivers/postgres/connect.go
Normal file
111
server/store/adapters/rdbms/drivers/postgres/connect.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "postgres"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
store.Register(Connect, SCHEMA, debugSchema)
|
||||
sql.Register(debugSchema, sqlmw.Driver(new(pq.Driver), instrumentation.Debug()))
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
func NewConfig(dsn string) (c *rdbms.ConnConfig, err error) {
|
||||
const (
|
||||
validScheme = "postgres"
|
||||
)
|
||||
var (
|
||||
scheme string
|
||||
u *url.URL
|
||||
)
|
||||
|
||||
if u, err = url.Parse(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres") {
|
||||
scheme = u.Scheme
|
||||
u.Scheme = validScheme
|
||||
} else {
|
||||
return nil, fmt.Errorf("expecting valid schema (postgres://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: scheme,
|
||||
DataSourceName: u.String(),
|
||||
DBName: strings.Trim(u.Path, "/"),
|
||||
MaskedDSN: u.Redacted(),
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(*pq.Error); ok {
|
||||
switch implErr.Code.Name() {
|
||||
case "unique_violation":
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
37
server/store/adapters/rdbms/drivers/postgres/dal.go
Normal file
37
server/store/adapters/rdbms/drivers/postgres/dal.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
128
server/store/adapters/rdbms/drivers/postgres/data_definer.go
Normal file
128
server/store/adapters/rdbms/drivers/postgres/data_definer.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *postgresDialect
|
||||
}
|
||||
|
||||
reTypeColumn struct {
|
||||
Dialect *postgresDialect
|
||||
Table string
|
||||
Column string
|
||||
Type *ddl.ColumnType
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (*ddl.Table, error) {
|
||||
return ddl.ConvertModel(m, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{Table: t, Dialect: dd.d})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, "public", dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &reTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *reTypeColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s ALTER COLUMN %s TYPE %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
c.Type.Name,
|
||||
), nil, nil
|
||||
}
|
||||
427
server/store/adapters/rdbms/drivers/postgres/dialect.go
Normal file
427
server/store/adapters/rdbms/drivers/postgres/dialect.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
postgresDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &postgresDialect{}
|
||||
|
||||
dialect = &postgresDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("postgres")
|
||||
goquDialectOptions = postgres.DialectOptions()
|
||||
quoteIdent = string(postgres.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
}
|
||||
)
|
||||
|
||||
func Dialect() *postgresDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (postgresDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (postgresDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (postgresDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (postgresDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d postgresDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("TO_JSON", expr)
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonExtract(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(true, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonExtractUnquote(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(false, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
// When dealing with multi values, we need to make a cross join with every element in the array
|
||||
// to achieve the same functionality as we had before
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
xpr, err := t.AttributeExpressionQuoted(colName)
|
||||
if err != nil {
|
||||
q = q.SetError(err)
|
||||
return q
|
||||
}
|
||||
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("JSONB_ARRAY_ELEMENTS_TEXT", xpr).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares postgresql compatible comparison of value and JSON array
|
||||
//
|
||||
// literal value = multi-value field / plain
|
||||
// 'value' <@ (v->'f0')::JSONB
|
||||
//
|
||||
// single-value field = multi-value field / plain
|
||||
// v->'f1'->0 <@ (v->'f0')::JSONB
|
||||
func (d postgresDialect) JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error) {
|
||||
return exp.NewLiteralExpression("(?)::JSONB <@ (?)::JSONB", needle, haystack), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d postgresDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTime:
|
||||
return &TypeTime{c}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (expr exp.Expression, err error) {
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeText:
|
||||
expr = exp.NewCastExpression(val, "TEXT")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
// convert to text first
|
||||
expr = exp.NewCastExpression(val, "TEXT")
|
||||
|
||||
// compare to text representation of true
|
||||
expr = exp.NewBooleanExpression(exp.EqOp, expr, exp.NewLiteralExpression(`true::TEXT`))
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
identExpr := exp.NewIdentifierExpression("", modelIdent, ident)
|
||||
|
||||
// truncate timestamp data type to second mark precision
|
||||
if attr.Type.Type() == dal.AttributeTypeTimestamp {
|
||||
return exp.NewLiteralExpression("date_trunc(?, ?)", "second", identExpr), nil
|
||||
}
|
||||
|
||||
// using column directly
|
||||
return exp.NewLiteralExpression("?", identExpr), nil
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "NUMERIC"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "NUMERIC"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
|
||||
if t.Timezone {
|
||||
col.Type.Name += "TZ"
|
||||
}
|
||||
|
||||
if t.Precision >= 0 {
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
|
||||
if t.Timezone {
|
||||
col.Type.Name += "TZ"
|
||||
}
|
||||
|
||||
if t.Precision >= 0 {
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
if numType := cast.ToString(t.Meta["rdbms:type"]); numType != "" {
|
||||
col.Type.Name = numType
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, 0, t.DefaultValue)
|
||||
break
|
||||
}
|
||||
|
||||
col.Type.Name = "NUMERIC"
|
||||
|
||||
switch {
|
||||
case t.Precision > 0 && t.Scale > 0:
|
||||
col.Type.Name += fmt.Sprintf("(%d, %d)", t.Precision, t.Scale)
|
||||
case t.Precision > 0:
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "JSONB"
|
||||
if col.Default, err = ddl.DefaultJSON(t.HasDefault, t.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
// @todo geometry type
|
||||
col.Type.Name = "JSONB"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BYTEA"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BOOLEAN"
|
||||
col.Default = ddl.DefaultBoolean(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "UUID"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (postgresDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"numeric": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
// @note this isn't entirely correct, but record revision lapsus made us
|
||||
"integer": true,
|
||||
},
|
||||
"integer": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
"numeric": true,
|
||||
},
|
||||
"timestamp": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
|
||||
"timestamptz": true,
|
||||
},
|
||||
"timestamptz": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"time": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
|
||||
"timetz": true,
|
||||
},
|
||||
"timetz": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"date": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"text": {},
|
||||
"varchar": {
|
||||
"text": true,
|
||||
},
|
||||
"jsonb": {},
|
||||
"bytea": {},
|
||||
"boolean": {
|
||||
"numeric": true,
|
||||
},
|
||||
"uuid": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// Check varchar size
|
||||
for i := len(assertMeta); i < 1; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 1; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0])
|
||||
|
||||
case assertName == "numeric" && targetName == "numeric":
|
||||
// Check numeric size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d postgresDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return castColumnDataToText("CONCAT", args...)
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
case "like", "nlike":
|
||||
if dalType, ok := n.Args[0].Meta["dal.Attribute"].(*dal.Attribute); ok {
|
||||
col, err := d.AttributeToColumn(dalType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if the type is id (numeric) data type, then cast it to text
|
||||
if col.Type.Name == "NUMERIC" {
|
||||
op := "LIKE"
|
||||
if ref == "nlike" {
|
||||
op = "NOT LIKE"
|
||||
}
|
||||
return castColumnDataToText(op, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d postgresDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
switch v := n.Value.V.(type) {
|
||||
case *expr.Boolean:
|
||||
// value handling for boolean is different in postgres
|
||||
// this was done to support value parsing for (IS $3) statement
|
||||
if cast.ToBool(v.Get()) {
|
||||
out = drivers.LiteralTRUE
|
||||
} else {
|
||||
out = drivers.LiteralFALSE
|
||||
}
|
||||
default:
|
||||
out = exp.NewLiteralExpression("?", n.Value.V.Get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d postgresDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, nst exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, nst)
|
||||
}
|
||||
|
||||
// castColumnDataToText converts column data to text.
|
||||
func castColumnDataToText(op string, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression(op, aa...), nil
|
||||
}
|
||||
156
server/store/adapters/rdbms/drivers/postgres/dialect_test.go
Normal file
156
server/store/adapters/rdbms/drivers/postgres/dialect_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (text)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "numeric(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "numeric(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "numeric fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "numeric doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(2,3)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(84)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "timestamp fits into timestamptz",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamptz"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamp"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "timestamptz doesn't fit into timestamp",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamp"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamptz"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time fits into timetz",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timetz"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "timetz doesn't fit into time",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timetz"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
d := postgresDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, schema, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect().Where(
|
||||
exp.ParseIdentifier("table_name").Eq(table),
|
||||
exp.ParseIdentifier("table_catalog").Eq(dbname),
|
||||
exp.ParseIdentifier("table_schema").Eq(schema),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"table_name",
|
||||
"column_name",
|
||||
"is_nullable",
|
||||
"data_type",
|
||||
// We'll use these two to get numeric's precision and scale
|
||||
"numeric_precision",
|
||||
"numeric_scale",
|
||||
// We'll use this one to get varchar length
|
||||
"character_maximum_length",
|
||||
).
|
||||
From("information_schema.columns").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("table_schema"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ordinal_position"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
// Making column type naming consistent between different databases
|
||||
typeMapping = map[string]string{
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"character varying": "varchar",
|
||||
}
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"table_name"`
|
||||
Column string `db:"column_name"`
|
||||
IsNullable string `db:"is_nullable"`
|
||||
Type string `db:"data_type"`
|
||||
Precision any `db:"numeric_precision"`
|
||||
Scale any `db:"numeric_scale"`
|
||||
MaxLength any `db:"character_maximum_length"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
tn, has := typeMapping[v.Type]
|
||||
if !has {
|
||||
tn = v.Type
|
||||
}
|
||||
|
||||
if tn == "numeric" {
|
||||
var (
|
||||
p, s int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.Precision) {
|
||||
p = cast.ToInt(v.Precision)
|
||||
}
|
||||
if !reflect2.IsNil(v.Scale) {
|
||||
s = cast.ToInt(v.Scale)
|
||||
}
|
||||
|
||||
if p+s > 0 {
|
||||
tn = fmt.Sprintf("%s(%d,%d)", tn, p, s)
|
||||
}
|
||||
}
|
||||
|
||||
if tn == "varchar" {
|
||||
var (
|
||||
l int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.MaxLength) {
|
||||
l = cast.ToInt(v.MaxLength)
|
||||
}
|
||||
|
||||
tn = fmt.Sprintf("%s(%d)", tn, l)
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: tn,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, schema string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndex = i.indexSelect().Where(
|
||||
exp.ParseIdentifier("indexname").Eq(index),
|
||||
exp.ParseIdentifier("tablename").Eq(table),
|
||||
exp.ParseIdentifier("schemaname").Eq(schema),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndex); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("index does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"tablename",
|
||||
"indexname",
|
||||
"indexdef",
|
||||
).
|
||||
From("pg_indexes").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("tablename"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("indexname"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"tablename"`
|
||||
Index string `db:"indexname"`
|
||||
Def string `db:"indexdef"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, len(aux))
|
||||
|
||||
//// iterate over results (aux) and populate ddl.Index struct
|
||||
for i, a := range aux {
|
||||
out[i] = &ddl.Index{}
|
||||
if err = parseIndexDefinition(a.Def, out[i]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parses postgesql specific index definition
|
||||
func parseIndexDefinition(def string, index *ddl.Index) error {
|
||||
const (
|
||||
TOKEN_INDEX = " INDEX "
|
||||
TOKEN_ON = " ON "
|
||||
TOKEN_USING = " USING "
|
||||
TOKEN_UNIQUE = " UNIQUE INDEX "
|
||||
TOKEN_WHERE = " WHERE "
|
||||
)
|
||||
|
||||
// if def contains TOKEN_UNIQUE, consider this an unique index
|
||||
index.Unique = strings.Contains(def, TOKEN_UNIQUE)
|
||||
|
||||
// parse postgesql index definition
|
||||
tpi := strings.Index(def, TOKEN_INDEX)
|
||||
tpo := strings.Index(def, TOKEN_ON)
|
||||
tpu := strings.Index(def, TOKEN_USING)
|
||||
tpw := strings.Index(def, TOKEN_WHERE)
|
||||
|
||||
if tpw == -1 {
|
||||
tpw = len(def)
|
||||
}
|
||||
|
||||
// extract index name
|
||||
index.Ident = def[tpi+len(TOKEN_INDEX) : tpo]
|
||||
|
||||
// extract index table
|
||||
index.TableIdent = def[tpo+len(TOKEN_ON) : tpu]
|
||||
index.TableIdent = strings.SplitN(index.TableIdent, ".", 2)[1]
|
||||
|
||||
// extract index fields
|
||||
fieldsDef := def[tpu+len(TOKEN_USING) : tpw]
|
||||
|
||||
typeAndFields := strings.SplitN(fieldsDef, " ", 2)
|
||||
|
||||
index.Type = typeAndFields[0]
|
||||
|
||||
fields := strings.Split(typeAndFields[1][1:len(typeAndFields[1])-1], ", ")
|
||||
index.Fields = make([]*ddl.IndexField, len(fields))
|
||||
for i, fd := range fields {
|
||||
f := &ddl.IndexField{}
|
||||
|
||||
if strings.Contains(fd, "(") {
|
||||
f.Expression = fd
|
||||
} else {
|
||||
f.Column = fd
|
||||
}
|
||||
|
||||
// @todo direction/sorting
|
||||
index.Fields[i] = f
|
||||
}
|
||||
|
||||
// extract predicate/where
|
||||
if tpw != len(def) {
|
||||
index.Predicate = def[tpw+len(TOKEN_WHERE)+1 : len(def)-1]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_parseIndexDefinition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
def string
|
||||
index *ddl.Index
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple index",
|
||||
def: "CREATE INDEX users_email ON public.users USING btree (email)",
|
||||
index: &ddl.Index{
|
||||
Ident: "users_email",
|
||||
TableIdent: "users",
|
||||
Type: "btree",
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with predicate",
|
||||
def: "CREATE UNIQUE INDEX users_email ON public.users USING btree (email) WHERE (deleted_at IS NULL)",
|
||||
index: &ddl.Index{
|
||||
Ident: "users_email",
|
||||
TableIdent: "users",
|
||||
Type: "btree",
|
||||
Unique: true,
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "email",
|
||||
},
|
||||
},
|
||||
Predicate: "deleted_at IS NULL",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expressions with predicate",
|
||||
def: "CREATE UNIQUE INDEX unique_language_handle ON public.templates USING btree (language, lower((handle)::text)) WHERE ((length((handle)::text) > 0) AND (deleted_at IS NULL))",
|
||||
index: &ddl.Index{
|
||||
Ident: "unique_language_handle",
|
||||
TableIdent: "templates",
|
||||
Type: "btree",
|
||||
Unique: true,
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "language",
|
||||
},
|
||||
{
|
||||
Expression: "lower((handle)::text)",
|
||||
},
|
||||
},
|
||||
Predicate: "(length((handle)::text) > 0) AND (deleted_at IS NULL)",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
index := &ddl.Index{}
|
||||
if err := parseIndexDefinition(tt.def, index); (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseIndexDefinition() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.index, index)
|
||||
})
|
||||
}
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/postgres/json.go
Normal file
42
server/store/adapters/rdbms/drivers/postgres/json.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeepIdentJSON constructs expression with chain of JSON operators
|
||||
// that point value inside JSON document
|
||||
//
|
||||
// https://www.postgresql.org/docs/9.3/functions-json.html
|
||||
func DeepIdentJSON(asJSON bool, jsonDoc exp.Expression, pp ...any) exp.LiteralExpression {
|
||||
var (
|
||||
sql strings.Builder
|
||||
last = len(pp) - 1
|
||||
)
|
||||
|
||||
sql.WriteString("?")
|
||||
|
||||
for i, p := range pp {
|
||||
sql.WriteString("-")
|
||||
|
||||
sql.WriteString(">")
|
||||
if i == last && !asJSON {
|
||||
sql.WriteString(">")
|
||||
}
|
||||
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString("'")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
sql.WriteString("'")
|
||||
case int:
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
default:
|
||||
panic("invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression(sql.String(), jsonDoc)
|
||||
}
|
||||
94
server/store/adapters/rdbms/drivers/postgres/json_test.go
Normal file
94
server/store/adapters/rdbms/drivers/postgres/json_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_DeepIdentJSON(t *testing.T) {
|
||||
var (
|
||||
pre = `SELECT `
|
||||
post = ` FROM "test"`
|
||||
|
||||
cc = []struct {
|
||||
input []interface{}
|
||||
sql string
|
||||
asJSON bool
|
||||
args []interface{}
|
||||
}{
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
sql: `"one"->>'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
sql: `"one"->2->>'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
sql: `"one"->'two'->>3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
asJSON: true,
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
asJSON: true,
|
||||
sql: `"one"->2->'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'->3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "tw'o"},
|
||||
sql: `"one"->>'tw''o'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
conv = func(asJSON bool, pp ...any) exp.SQLExpression {
|
||||
return goqu.Dialect("postgres").
|
||||
Select(DeepIdentJSON(asJSON, exp.ParseIdentifier(pp[0].(string)), pp[1:]...)).From("test")
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.sql, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
sql, args, err := conv(c.asJSON, c.input...).ToSQL()
|
||||
r.NoError(err)
|
||||
r.Equal(pre+c.sql+post, sql)
|
||||
r.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
192
server/store/adapters/rdbms/drivers/postgres/ql.go
Normal file
192
server/store/adapters/rdbms/drivers/postgres/ql.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"concat": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// need to force text type on all arguments
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("CONCAT", aa...)
|
||||
},
|
||||
},
|
||||
"instr": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// need to force text type on all arguments
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("INSTR", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// filtering
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("NOW")
|
||||
},
|
||||
},
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("QUARTER FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("YEAR FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("MONTH FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("?::TIMESTAMPTZ", args[0])
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("?::DATE", args[0])
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("DAY FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"week": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("WEEK FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"time": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("DATE_TRUNC('second', ?::TIME)::TIME", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
// @todo replace given argument before constructing sql
|
||||
"date_format": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("TO_CHAR",
|
||||
exp.NewLiteralExpression("?::TIMESTAMPTZ", args[0]),
|
||||
exp.NewLiteralExpression("?::TEXT", translateDateFormatParam(args[1])),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
"interval": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// The problem here is that PGSQL, to my findings, doesn't have functions to add/sub dates
|
||||
// like MySQL for example.
|
||||
//
|
||||
// We need to construct an expression in the lines of `INTERVAL 'N UNIT'` which
|
||||
// then becomes, for example, d + INTERVAL 'N UNIT'.
|
||||
//
|
||||
// The problem #2 is that we can't just use value placeholders in string literals
|
||||
// nor is there a 2 arg function to make an interval. There is a make_interval function
|
||||
// but that one won't do.
|
||||
//
|
||||
// So...
|
||||
// (? || 'S') makes the interval label a plural because MySQL uses singular and that
|
||||
// is what QL supports. PgSQL uses plural, such as years, months, and days.
|
||||
//
|
||||
// The rest of the expression is just to construct the string which can then be casted to INTERVAL
|
||||
// which can then be used in date math, which is done with regular math operators.
|
||||
return exp.NewLiteralExpression("(?::INTEGER || ' ' || (? || 'S'))::INTERVAL", args[1], args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"date_add": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(? + ?)", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"date_sub": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(? - ?)", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// functions currently unsupported in PostgreSQL store backend
|
||||
// "STD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
}.ExprHandlers()
|
||||
)
|
||||
|
||||
func translateDateFormatParam(e interface{}) interface{} {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e
|
||||
}
|
||||
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
return dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func dateFormatReplacer(s string) string {
|
||||
return strings.NewReplacer(
|
||||
// @todo Doing ...%dT%H... (for iso timestamp) pgsql doesn't format it correctly
|
||||
// so I'm covering this edge case.
|
||||
// We should fix this properly when we redo record storage.
|
||||
`%dT%H`, `DD"T"HH24`,
|
||||
|
||||
`%a`, `Dy`,
|
||||
`%b`, `Mon`,
|
||||
`%c`, `FMMM`,
|
||||
`%d`, `DD`,
|
||||
`%e`, `FMDD`,
|
||||
`%f`, `US`,
|
||||
`%H`, `HH24`,
|
||||
`%h`, `HH12`,
|
||||
`%I`, `HH12`,
|
||||
`%i`, `MI`,
|
||||
`%j`, `DDD`,
|
||||
`%k`, `FMHH24`,
|
||||
`%l`, `FMHH12`,
|
||||
`%M`, `FMMonth`,
|
||||
`%m`, `MM`,
|
||||
`%p`, `AM`,
|
||||
`%r`, `HH12:MI:SS AM`,
|
||||
`%S`, `SS`,
|
||||
`%s`, `SS`,
|
||||
`%T`, `HH24:MI:SS`,
|
||||
`%W`, `FMDay`,
|
||||
`%Y`, `YYYY`,
|
||||
`%y`, `YY`,
|
||||
`%%`, `%`,
|
||||
).Replace(s)
|
||||
}
|
||||
88
server/store/adapters/rdbms/drivers/postgres/ql_test.go
Normal file
88
server/store/adapters/rdbms/drivers/postgres/ql_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @todo Ql functions should be under store/tests so it can be tested across all drivers along with generated tests.
|
||||
//
|
||||
// for now, Its test coverage is limited per driver.
|
||||
func TestConverter(t *testing.T) {
|
||||
const SELECT = "SELECT "
|
||||
var (
|
||||
conv = ql.Converter(ql.RefHandler(dialect.ExprHandler))
|
||||
|
||||
cases = []struct {
|
||||
qry string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
qry: `quarter('2022-07-21')`,
|
||||
sql: `EXTRACT(QUARTER FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `year('2022-07-21')`,
|
||||
sql: `EXTRACT(YEAR FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `month('2022-07-21')`,
|
||||
sql: `EXTRACT(MONTH FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `timestamp('2022-07-21')`,
|
||||
sql: `$1::TIMESTAMPTZ`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date('2022-07-21')`,
|
||||
sql: `$1::DATE`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `week('2022-07-21')`,
|
||||
sql: `EXTRACT(WEEK FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `time('2022-07-21 12:41')`,
|
||||
sql: `DATE_TRUNC('second', $1::TIME)::TIME`,
|
||||
args: []any{"2022-07-21 12:41"},
|
||||
},
|
||||
{
|
||||
qry: `date_format('2022-07-21','%a')`,
|
||||
sql: `TO_CHAR($1::TIMESTAMPTZ, $2::TEXT)`,
|
||||
args: []any{"2022-07-21", "Dy"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.qry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
ee, err := conv.Parse(c.qry)
|
||||
req.NoError(err)
|
||||
|
||||
sql, args, err := dialect.GOQU().Select(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
|
||||
p := strings.Index(sql, SELECT)
|
||||
req.Zero(p)
|
||||
|
||||
sql = sql[p+len(SELECT):]
|
||||
|
||||
req.Equal(c.sql, sql)
|
||||
req.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/postgres/types.go
Normal file
42
server/store/adapters/rdbms/drivers/postgres/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(t.Timezone, t.Precision)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
174
server/store/adapters/rdbms/drivers/sqlite/connect.go
Normal file
174
server/store/adapters/rdbms/drivers/sqlite/connect.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "sqlite3"
|
||||
|
||||
// alternative s hema with custom driver
|
||||
altSchema = SCHEMA + "+alt"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
var (
|
||||
// make a custom driver with REGEX support
|
||||
customDriver = &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) (err error) {
|
||||
// register regexp function and use Go's regexp fn
|
||||
if err = conn.RegisterFunc("regexp", fnRegExp, true); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.RegisterFunc("json_array_contains", sqliteFuncJsonArrayContains, true); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func fnRegExp(pattern string, s string) (matched bool, err error) {
|
||||
matched, err = regexp.MatchString(pattern, s)
|
||||
return
|
||||
}
|
||||
|
||||
func init() {
|
||||
// register alter driver
|
||||
sql.Register(altSchema, customDriver)
|
||||
// register debug driver
|
||||
sql.Register(debugSchema, sqlmw.Driver(customDriver, instrumentation.Debug()))
|
||||
|
||||
store.Register(Connect, SCHEMA, altSchema, debugSchema)
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
TxRetryLimit: -1,
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ConnectInMemory(ctx context.Context) (s store.Storer, err error) {
|
||||
return Connect(ctx, altSchema+"://file::memory:?cache=shared&mode=memory")
|
||||
}
|
||||
|
||||
func ConnectInMemoryWithDebug(ctx context.Context) (s store.Storer, err error) {
|
||||
return Connect(ctx, debugSchema+"://file::memory:?cache=shared&mode=memory")
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
func NewConfig(in string) (*rdbms.ConnConfig, error) {
|
||||
const (
|
||||
schemaDel = "://"
|
||||
)
|
||||
|
||||
var (
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: altSchema,
|
||||
}
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(in, SCHEMA+schemaDel), strings.HasPrefix(in, altSchema+schemaDel):
|
||||
// no special handling
|
||||
case strings.HasPrefix(in, debugSchema+schemaDel):
|
||||
c.DriverName = debugSchema
|
||||
default:
|
||||
return nil, fmt.Errorf("expecting valid schema (sqlite3://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
// reassemble DSN with base schema
|
||||
c.DataSourceName = in[strings.Index(in, schemaDel)+len(schemaDel):]
|
||||
|
||||
// Nothing to mask for sqlite3 DSN
|
||||
c.MaskedDSN = c.DataSourceName
|
||||
|
||||
// Set to zero
|
||||
// Otherwise SQLite (in-memory) disconnects
|
||||
// and all tables and data is lost
|
||||
c.ConnMaxLifetime = 0
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Transactions are disabled on SQLite
|
||||
//func txRetryErrHandler(try int, err error) bool {
|
||||
// for errors.Unwrap(err) != nil {
|
||||
// err = errors.Unwrap(err)
|
||||
// }
|
||||
//
|
||||
// var sqliteErr, ok = err.(sqlite3.Error)
|
||||
// if !ok {
|
||||
// return false
|
||||
// }
|
||||
//
|
||||
// switch sqliteErr.Code {
|
||||
// case sqlite3.ErrLocked:
|
||||
// return true
|
||||
//
|
||||
// }
|
||||
//
|
||||
// return false
|
||||
//}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(sqlite3.Error); ok {
|
||||
switch implErr.ExtendedCode {
|
||||
case sqlite3.ErrConstraintUnique:
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
37
server/store/adapters/rdbms/drivers/sqlite/dal.go
Normal file
37
server/store/adapters/rdbms/drivers/sqlite/dal.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, altSchema, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
114
server/store/adapters/rdbms/drivers/sqlite/data_definer.go
Normal file
114
server/store/adapters/rdbms/drivers/sqlite/data_definer.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// @todo this is unmodified copy of postgres's data definer struct!
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *sqliteDialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (*ddl.Table, error) {
|
||||
return ddl.ConvertModel(m, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, "public", dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.ReTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
338
server/store/adapters/rdbms/drivers/sqlite/dialect.go
Normal file
338
server/store/adapters/rdbms/drivers/sqlite/dialect.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/cast2"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
sqliteDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &sqliteDialect{}
|
||||
|
||||
dialect = &sqliteDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("sqlite3")
|
||||
goquDialectOptions *sqlgen.SQLDialectOptions
|
||||
quoteIdent = string(sqlite3.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
|
||||
ExpandedJsonColumnSelector: func(ident string) exp.Expression {
|
||||
return exp.NewLiteralExpression(fmt.Sprintf(`%s.value`, ident))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goquDialectOptions = sqlite3.DialectOptions()
|
||||
|
||||
// https://github.com/doug-martin/goqu/v9/pull/330
|
||||
goquDialectOptions.TruncateClause = []byte("DELETE FROM")
|
||||
|
||||
// Overriding vanila SQLite dialect
|
||||
goqu.RegisterDialect("sqlite3", goquDialectOptions)
|
||||
}
|
||||
|
||||
func Dialect() *sqliteDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (sqliteDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d sqliteDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("json_each",
|
||||
goqu.C("values").Table("compose_record"),
|
||||
exp.NewLiteralExpression(fmt.Sprintf(`'$.%s'`, colName)),
|
||||
).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func (sqliteDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (sqliteDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (sqliteDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d sqliteDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("JSON_QUOTE", expr)
|
||||
}
|
||||
|
||||
func (sqliteDialect) JsonExtract(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(true, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (sqliteDialect) JsonExtractUnquote(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(false, ident, pp...), nil
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares SQLite compatible comparison of value and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
//
|
||||
// 'aaa' in (select value from json_each(v->'f2'))
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// json_extract(v, '$.f3[0]') in (select value from json_each(v->'f2'));
|
||||
//
|
||||
// Unfortunately SQLite converts boolean values into 0 and 1 when decoding from
|
||||
// JSON and we need a special handler for that.
|
||||
func (sqliteDialect) JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error) {
|
||||
return exp.NewLiteralExpression("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d sqliteDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeDate:
|
||||
return &TypeDate{c}
|
||||
case *dal.TypeTimestamp:
|
||||
return &TypeTimestamp{c}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.Expression
|
||||
)
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
// we have way of handling TZ here (not sure how to do that with strftime() function)
|
||||
// if we use %Z at the end strftime will always return NULL
|
||||
if t.Precision == 0 {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%dT%H:%M:%S", val)
|
||||
} else {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%dT%H:%M:%f", val)
|
||||
}
|
||||
|
||||
case *dal.TypeTime:
|
||||
// we have way of handling TZ here (not sure how to do that with strftime() function)
|
||||
// if we use %Z at the end strftime will always return NULL
|
||||
if t.Precision == 0 {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%H:%M:%S", val)
|
||||
} else {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%H:%M:%f", val)
|
||||
}
|
||||
|
||||
case *dal.TypeDate:
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%d", val)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
c = exp.NewCastExpression(val, "NUMERIC")
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT"
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeTime:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "TEXT"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "NUMERIC"
|
||||
// @todo precision, scale? x
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "TEXT"
|
||||
if col.Default, err = ddl.DefaultJSON(t.HasDefault, t.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BLOB"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BOOLEAN"
|
||||
col.Default = ddl.DefaultBoolean(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (sqliteDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"timestamp": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"text": {
|
||||
"char": true,
|
||||
},
|
||||
"numeric": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"blob": {},
|
||||
"boolean": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
"bigint": true,
|
||||
"numeric": true,
|
||||
},
|
||||
"char": {
|
||||
"text": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "char" && targetName == "char":
|
||||
// Check char size
|
||||
return baseMatch && assertMeta[0] <= targetMeta[0]
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d sqliteDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return exp.NewLiteralExpression("?"+strings.Repeat(" || ?", len(args)-1), cast2.Anys(args...)...), nil
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
return ql.DefaultValueHandler(n)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, nst exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// @todo this is unmodified copy of mysql's information schema struct!
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, schema, dbname string) (tbl *ddl.Table, err error) {
|
||||
var (
|
||||
// https://www.sqlite.org/pragma.html
|
||||
query = fmt.Sprintf(`PRAGMA table_info(%s)`, Dialect().QuoteIdent(table))
|
||||
aux = make([]struct {
|
||||
CID any `db:"cid"`
|
||||
Name string `db:"name"`
|
||||
Type string `db:"type"`
|
||||
NotNull bool `db:"notnull"`
|
||||
DefaultValue sql.NullString `db:"dflt_value"`
|
||||
PrimaryKey int `db:"pk"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = sqlx.SelectContext(ctx, i.conn, &aux, query); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(aux) == 0 {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
|
||||
tbl = &ddl.Table{
|
||||
Ident: table,
|
||||
Columns: make([]*ddl.Column, len(aux)),
|
||||
}
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
tbl.Columns[p] = &ddl.Column{
|
||||
Ident: a.Name,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: a.Type,
|
||||
Null: !a.NotNull,
|
||||
},
|
||||
Default: a.DefaultValue.String,
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, schema string) (*ddl.Index, error) {
|
||||
// for now, there is no need do implement this
|
||||
return nil, errors.NotFound("index %q not found", index)
|
||||
}
|
||||
104
server/store/adapters/rdbms/drivers/sqlite/json.go
Normal file
104
server/store/adapters/rdbms/drivers/sqlite/json.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/valyala/fastjson"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeepIdentJSON constructs expression with chain of JSON operators
|
||||
// that point value inside JSON document
|
||||
//
|
||||
// https://www.postgresql.org/docs/9.3/functions-json.html
|
||||
func DeepIdentJSON(asJSON bool, jsonDoc exp.Expression, pp ...any) exp.LiteralExpression {
|
||||
var (
|
||||
sql strings.Builder
|
||||
last = len(pp) - 1
|
||||
)
|
||||
|
||||
sql.WriteString("?")
|
||||
|
||||
for i, p := range pp {
|
||||
sql.WriteString("-")
|
||||
|
||||
sql.WriteString(">")
|
||||
if i == last && !asJSON {
|
||||
sql.WriteString(">")
|
||||
}
|
||||
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString("'")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
sql.WriteString("'")
|
||||
case int:
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
default:
|
||||
panic("invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression(sql.String(), jsonDoc)
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
|
||||
func sqliteFuncJsonArrayContains(needle, haystack []byte) (_ bool, err error) {
|
||||
var n, h, i *fastjson.Value
|
||||
|
||||
if h, err = fastjson.ParseBytes(haystack); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.Type() != fastjson.TypeArray {
|
||||
err = fmt.Errorf("haystack is %s, expecting array", h.Type())
|
||||
return
|
||||
}
|
||||
|
||||
if n, err = fastjson.ParseBytes(needle); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, i = range h.GetArray() {
|
||||
if i.Type() != n.Type() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch i.Type() {
|
||||
case fastjson.TypeFalse, fastjson.TypeTrue:
|
||||
if i.GetBool() == n.GetBool() {
|
||||
return true, nil
|
||||
}
|
||||
case fastjson.TypeNumber, fastjson.TypeString:
|
||||
if i.String() == n.String() {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
129
server/store/adapters/rdbms/drivers/sqlite/json_test.go
Normal file
129
server/store/adapters/rdbms/drivers/sqlite/json_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_DeepIdentJSON(t *testing.T) {
|
||||
var (
|
||||
pre = `SELECT `
|
||||
post = ` FROM "test"`
|
||||
|
||||
cc = []struct {
|
||||
input []interface{}
|
||||
sql string
|
||||
asJSON bool
|
||||
args []interface{}
|
||||
}{
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
sql: `"one"->>'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
sql: `"one"->2->>'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
sql: `"one"->'two'->>3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
asJSON: true,
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
asJSON: true,
|
||||
sql: `"one"->2->'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'->3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "tw'o"},
|
||||
sql: `"one"->>'tw''o'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
conv = func(asJSON bool, pp ...any) exp.SQLExpression {
|
||||
return goqu.Dialect("postgres").
|
||||
Select(DeepIdentJSON(asJSON, exp.ParseIdentifier(pp[0].(string)), pp[1:]...)).From("test")
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.sql, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
sql, args, err := conv(c.asJSON, c.input...).ToSQL()
|
||||
r.NoError(err)
|
||||
r.Equal(pre+c.sql+post, sql)
|
||||
r.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
157
server/store/adapters/rdbms/drivers/sqlite/ql.go
Normal file
157
server/store/adapters/rdbms/drivers/sqlite/ql.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
// filtering
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE",
|
||||
exp.NewLiteralExpression("'NOW'"),
|
||||
)
|
||||
},
|
||||
},
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(CAST(STRFTIME('%m', ?) AS INTEGER) + 2) / 3", args[0])
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%Y'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%m'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%Y-%m-%dT00:00:00Z'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%d'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"week": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%W'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"datetime": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATETIME", args[0])
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATETIME", args[0])
|
||||
},
|
||||
},
|
||||
"date_format": {
|
||||
HandlerE: func(args ...exp.Expression) (exp.Expression, error) {
|
||||
format, err := supportedDateFormatParams(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
format,
|
||||
args[0],
|
||||
), nil
|
||||
},
|
||||
},
|
||||
|
||||
// functions currently unsupported in SQLite store backend
|
||||
// "DATE_ADD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
// "DATE_SUB": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
// "STD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
}.ExprHandlers()
|
||||
|
||||
supportedSubstitutions = map[string]bool{
|
||||
"d": true,
|
||||
"H": true,
|
||||
"j": true,
|
||||
"m": true,
|
||||
"M": true,
|
||||
"S": true,
|
||||
"w": true,
|
||||
"W": true,
|
||||
"Y": true,
|
||||
"%": true,
|
||||
}
|
||||
)
|
||||
|
||||
func supportedDateFormatParams(e interface{}) (interface{}, error) {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e, fmt.Errorf("unknown date format")
|
||||
}
|
||||
|
||||
var format string
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
format = dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
} else {
|
||||
return e, fmt.Errorf("date format not found")
|
||||
}
|
||||
|
||||
r := regexp.MustCompile(`%(?P<sub>.)`)
|
||||
|
||||
for _, m := range r.FindAllStringSubmatch(format, -1) {
|
||||
if len(m) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := supportedSubstitutions[m[1]]; !ok {
|
||||
return e, fmt.Errorf("format substitution not supported: %%%s", m[1])
|
||||
}
|
||||
}
|
||||
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func dateFormatReplacer(format string) string {
|
||||
return strings.NewReplacer(
|
||||
`%i`, `%M`,
|
||||
`%U`, `%W`,
|
||||
).Replace(format)
|
||||
}
|
||||
91
server/store/adapters/rdbms/drivers/sqlite/ql_test.go
Normal file
91
server/store/adapters/rdbms/drivers/sqlite/ql_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @todo Ql functions should be under store/tests so it can be tested across all drivers along with generated tests.
|
||||
// for now, Its test coverage is limited per driver.
|
||||
func TestConverter(t *testing.T) {
|
||||
const SELECT = "SELECT "
|
||||
var (
|
||||
conv = ql.Converter(ql.RefHandler(dialect.ExprHandler))
|
||||
|
||||
cases = []struct {
|
||||
qry string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
qry: `now()`,
|
||||
sql: `DATE('NOW')`,
|
||||
args: []any{},
|
||||
},
|
||||
{
|
||||
qry: `quarter('2022-07-21')`,
|
||||
sql: `(CAST(STRFTIME('%m', ?) AS INTEGER) + 2) / 3`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `year('2022-07-21')`,
|
||||
sql: `STRFTIME('%Y', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `month('2022-07-21')`,
|
||||
sql: `STRFTIME('%m', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date('2022-07-21')`,
|
||||
sql: `STRFTIME('%Y-%m-%dT00:00:00Z', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `week('2022-07-21')`,
|
||||
sql: `STRFTIME('%W', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `datetime('2022-07-21')`,
|
||||
sql: `DATETIME(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `timestamp('2022-07-21')`,
|
||||
sql: `DATETIME(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date_format('2022-07-21','%d')`,
|
||||
sql: `STRFTIME(?, ?)`,
|
||||
args: []any{"%d", "2022-07-21"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.qry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
ee, err := conv.Parse(c.qry)
|
||||
req.NoError(err)
|
||||
|
||||
sql, args, err := dialect.GOQU().Select(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
|
||||
p := strings.Index(sql, SELECT)
|
||||
req.Zero(p)
|
||||
|
||||
sql = sql[p+len(SELECT):]
|
||||
|
||||
req.Equal(c.sql, sql)
|
||||
req.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
104
server/store/adapters/rdbms/drivers/sqlite/types.go
Normal file
104
server/store/adapters/rdbms/drivers/sqlite/types.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"github.com/spf13/cast"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeDate struct{ *dal.TypeDate }
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
TypeTimestamp struct{ *dal.TypeTimestamp }
|
||||
)
|
||||
|
||||
func (*TypeDate) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
|
||||
func (t *TypeDate) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
parsed, err := cast.StringToDate(dec.String)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
return parsed.Format(drivers.DateLayout), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeDate) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, t.Precision)), dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeTimestamp) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTimestamp) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Timestamp", raw)
|
||||
}
|
||||
|
||||
if dec.Valid {
|
||||
val := dec.Time.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
return val, dec.Valid, nil
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
switch c := val.(type) {
|
||||
case string:
|
||||
parsed, err := cast.StringToDate(c)
|
||||
if err != nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
val = parsed.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
|
||||
case time.Time:
|
||||
val = c.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
187
server/store/adapters/rdbms/drivers/table.go
Normal file
187
server/store/adapters/rdbms/drivers/table.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
// TableCodec is an RDBMS representation of data.Model structure and its arguments
|
||||
TableCodec interface {
|
||||
Columns() []Column
|
||||
Ident() exp.IdentifierExpression
|
||||
MakeScanBuffer() []any
|
||||
Encode(r dal.ValueGetter) (_ []any, err error)
|
||||
Decode(buf []any, r dal.ValueSetter) (err error)
|
||||
AttributeExpression(string) (exp.Expression, error)
|
||||
AttributeExpressionQuoted(string) (exp.Expression, error)
|
||||
}
|
||||
|
||||
// GenericTableCodec is a generic implementation of TableCodec
|
||||
GenericTableCodec struct {
|
||||
// table identifier (name)
|
||||
ident exp.IdentifierExpression
|
||||
|
||||
// all columns we're selecting from when
|
||||
// we're selecting from all columns
|
||||
columns []Column
|
||||
|
||||
model *dal.Model
|
||||
dialect Dialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ TableCodec = &GenericTableCodec{}
|
||||
)
|
||||
|
||||
func NewTableCodec(m *dal.Model, d Dialect) *GenericTableCodec {
|
||||
gtc := &GenericTableCodec{
|
||||
dialect: d,
|
||||
model: m,
|
||||
ident: exp.NewIdentifierExpression("", m.Ident, ""),
|
||||
}
|
||||
|
||||
var (
|
||||
colIdent string
|
||||
attr *dal.Attribute
|
||||
done = make(map[string]bool)
|
||||
cols = make([]Column, 0, len(m.Attributes))
|
||||
)
|
||||
|
||||
for a := range m.Attributes {
|
||||
attr = m.Attributes[a]
|
||||
colIdent = attr.StoreIdent()
|
||||
|
||||
switch attr.Store.(type) {
|
||||
case *dal.CodecRecordValueSetJSON:
|
||||
if done[colIdent] {
|
||||
continue
|
||||
}
|
||||
|
||||
// when dealing with encoded types there is probably
|
||||
// a different column that can handle the encoded payload
|
||||
cols = append(cols, &SimpleJsonDocColumn{
|
||||
name: colIdent,
|
||||
attributes: collectStdRecordValueJSONColumns(colIdent, m.Attributes...),
|
||||
})
|
||||
default:
|
||||
cols = append(cols, NewSingleValueColumn(d, attr))
|
||||
}
|
||||
|
||||
done[colIdent] = true
|
||||
}
|
||||
|
||||
gtc.columns = cols
|
||||
|
||||
return gtc
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Ident() exp.IdentifierExpression {
|
||||
return t.ident
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Columns() []Column {
|
||||
return t.columns
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) MakeScanBuffer() []any {
|
||||
out := make([]any, len(t.columns))
|
||||
|
||||
for c := range t.columns {
|
||||
out[c] = t.columns[c].Type().MakeScanBuffer()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Encode(r dal.ValueGetter) (_ []any, err error) {
|
||||
enc := make([]any, len(t.columns))
|
||||
|
||||
for c := range t.columns {
|
||||
enc[c], err = t.columns[c].Encode(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Decode(buf []any, r dal.ValueSetter) (err error) {
|
||||
if len(buf) != len(t.columns) {
|
||||
return fmt.Errorf("columns incompatilbe with scan buffer")
|
||||
}
|
||||
|
||||
for i, c := range t.columns {
|
||||
if err = c.Decode(buf[i], r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) AttributeExpressionQuoted(ident string) (exp.Expression, error) {
|
||||
return t.attributeExpression(true, ident)
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) AttributeExpression(ident string) (exp.Expression, error) {
|
||||
return t.attributeExpression(false, ident)
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) attributeExpression(quoted bool, ident string) (exp.Expression, error) {
|
||||
attr := t.model.Attributes.FindByIdent(ident)
|
||||
|
||||
if attr == nil {
|
||||
return nil, fmt.Errorf("unknown attribute %q", ident)
|
||||
}
|
||||
|
||||
switch s := attr.Store.(type) {
|
||||
case *dal.CodecAlias:
|
||||
return t.dialect.AttributeExpression(attr, t.model.Ident, s.Ident)
|
||||
|
||||
case *dal.CodecRecordValueSetJSON:
|
||||
idfExpr := exp.NewIdentifierExpression("", t.model.Ident, s.Ident)
|
||||
|
||||
var lit exp.Expression
|
||||
var err error
|
||||
if quoted {
|
||||
lit, err = t.dialect.JsonExtract(idfExpr, attr.Ident)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lit, nil
|
||||
} else {
|
||||
// using JSON to handle embedded values
|
||||
lit, err = t.dialect.JsonExtractUnquote(idfExpr, attr.Ident, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.dialect.AttributeCast(attr, lit)
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", t.model.Ident, ident)), nil
|
||||
}
|
||||
|
||||
func collectStdRecordValueJSONColumns(ident string, aa ...*dal.Attribute) []*dal.Attribute {
|
||||
filtered := make([]*dal.Attribute, 0)
|
||||
for _, a := range aa {
|
||||
storeType, is := a.Store.(*dal.CodecRecordValueSetJSON)
|
||||
if !is {
|
||||
continue
|
||||
}
|
||||
|
||||
if ident != storeType.Ident {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
3
server/store/adapters/rdbms/drivers/tests/.env.example
Normal file
3
server/store/adapters/rdbms/drivers/tests/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
#DB_DSN=mysql://uod:pwd@tcp(localhost:3306)/dbname?collation=utf8mb4_general_ci
|
||||
#DB_DSN=postgres://uid:pwd@localhost:5432/dbame?sslmode=disable
|
||||
#DB_DSN=sqlite3://file::memory:?cache=shared&mode=memory
|
||||
1
server/store/adapters/rdbms/drivers/tests/.gitignore
vendored
Normal file
1
server/store/adapters/rdbms/drivers/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
17
server/store/adapters/rdbms/drivers/tests/connection_test.go
Normal file
17
server/store/adapters/rdbms/drivers/tests/connection_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectionPing(t *testing.T) {
|
||||
t.Logf("testing %s", conn.dsn)
|
||||
|
||||
toCtx, cfn := context.WithTimeout(ctx, time.Second)
|
||||
defer cfn()
|
||||
|
||||
require.NoError(t, conn.db.PingContext(toCtx))
|
||||
}
|
||||
68
server/store/adapters/rdbms/drivers/tests/dal_model_test.go
Normal file
68
server/store/adapters/rdbms/drivers/tests/dal_model_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestModelManagement(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
log = logger.Default()
|
||||
)
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
if conn.isSQLite {
|
||||
t.Skip("sqlite does not support index management")
|
||||
}
|
||||
|
||||
const (
|
||||
dalConnID = 1
|
||||
dalTableName = "lil_dal_test"
|
||||
)
|
||||
|
||||
_, err := conn.db.Exec("DROP TABLE IF EXISTS " + dalTableName)
|
||||
req.NoError(err)
|
||||
|
||||
cw := dal.MakeConnection(
|
||||
dalConnID,
|
||||
conn.store.ToDalConn(),
|
||||
dal.ConnectionParams{},
|
||||
dal.ConnectionConfig{},
|
||||
)
|
||||
|
||||
svc, err := dal.New(log, true)
|
||||
req.NoError(err)
|
||||
req.NoError(svc.ReplaceConnection(ctx, cw, true))
|
||||
|
||||
_, err = svc.ReplaceModel(ctx, nil, &dal.Model{
|
||||
Ident: dalTableName,
|
||||
ConnectionID: dalConnID,
|
||||
Attributes: dal.AttributeSet{
|
||||
&dal.Attribute{
|
||||
Ident: "ID",
|
||||
PrimaryKey: true,
|
||||
Type: &dal.TypeID{},
|
||||
Store: &dal.CodecAlias{Ident: "id"},
|
||||
},
|
||||
|
||||
&dal.Attribute{
|
||||
Ident: "OwnerID",
|
||||
Sortable: true,
|
||||
Type: &dal.TypeText{},
|
||||
Store: &dal.CodecAlias{Ident: "rel_owner"},
|
||||
},
|
||||
},
|
||||
})
|
||||
req.NoError(err)
|
||||
|
||||
_, err = conn.db.Exec("DROP TABLE " + dalTableName)
|
||||
req.NoError(err)
|
||||
}
|
||||
169
server/store/adapters/rdbms/drivers/tests/dialect_test.go
Normal file
169
server/store/adapters/rdbms/drivers/tests/dialect_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONOp(t *testing.T) {
|
||||
var (
|
||||
tbl = ddl.Table{
|
||||
Ident: "test_json_path_test",
|
||||
Columns: []*ddl.Column{
|
||||
{Ident: "c", Type: &ddl.ColumnType{Name: "JSON"}},
|
||||
},
|
||||
Temporary: true,
|
||||
}
|
||||
|
||||
req = require.New(t)
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
count = func(req *require.Assertions, expr ...exp.Expression) int {
|
||||
var (
|
||||
out = struct {
|
||||
Count int `db:"count"`
|
||||
}{}
|
||||
)
|
||||
|
||||
query := conn.dialect.GOQU().
|
||||
Select(goqu.COUNT(goqu.Star()).As("count")).
|
||||
From(tbl.Ident).
|
||||
Where(expr...)
|
||||
|
||||
err := conn.store.QueryOne(ctx, query, &out)
|
||||
req.NoError(err)
|
||||
|
||||
return out.Count
|
||||
}
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
countDeepJSON = func(req *require.Assertions, val string) int {
|
||||
col := exp.NewIdentifierExpression("", tbl.Ident, "c")
|
||||
diJSON, err := conn.dialect.JsonExtractUnquote(col, "a", "b", "c")
|
||||
req.NoError(err)
|
||||
|
||||
return count(req, exp.NewLiteralExpression("?", diJSON).Eq(val))
|
||||
}
|
||||
|
||||
a2e = func(attr *dal.Attribute) exp.Expression {
|
||||
expr, err := conn.dialect.JsonExtract(
|
||||
exp.NewIdentifierExpression("", tbl.Ident, attr.StoreIdent()),
|
||||
attr.Ident,
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
return expr
|
||||
}
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
countContains = func(req *require.Assertions, attr *dal.Attribute, val any) int {
|
||||
contains, err := conn.dialect.JsonArrayContains(
|
||||
exp.NewLiteralExpression("?", val),
|
||||
a2e(attr),
|
||||
)
|
||||
req.NoError(err)
|
||||
|
||||
return count(req, contains)
|
||||
}
|
||||
|
||||
// test utility function
|
||||
insert = func(vv ...string) {
|
||||
for _, val := range vv {
|
||||
insert := conn.dialect.GOQU().
|
||||
Insert(tbl.Ident).
|
||||
Cols("c").
|
||||
Vals(goqu.Vals{val})
|
||||
|
||||
req.NoError(conn.store.Exec(ctx, insert))
|
||||
}
|
||||
}
|
||||
|
||||
asJSON = func(val any) string {
|
||||
enc, err := json.Marshal(val)
|
||||
req.NoError(err)
|
||||
return string(enc)
|
||||
}
|
||||
|
||||
jsonStore = &dal.CodecRecordValueSetJSON{Ident: "c"}
|
||||
numAttr = &dal.Attribute{Ident: "n", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textAttr = &dal.Attribute{Ident: "s", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolAttr = &dal.Attribute{Ident: "b", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
|
||||
numCheck0Attr = &dal.Attribute{Ident: "nCheck0", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textCheck0Attr = &dal.Attribute{Ident: "sCheck0", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolCheck0Attr = &dal.Attribute{Ident: "bCheck0", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
|
||||
numCheck1Attr = &dal.Attribute{Ident: "nCheck1", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textCheck1Attr = &dal.Attribute{Ident: "sCheck1", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolCheck1Attr = &dal.Attribute{Ident: "bCheck1", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
)
|
||||
|
||||
{
|
||||
// check if table exists, drop it and create a new, temporary one
|
||||
|
||||
dd := conn.store.DataDefiner
|
||||
|
||||
// utilize DDL and create a table with a json column
|
||||
_, err := dd.TableLookup(ctx, tbl.Ident)
|
||||
if errors.IsNotFound(err) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
err = dd.TableDrop(ctx, tbl.Ident)
|
||||
}
|
||||
|
||||
req.NoError(err)
|
||||
req.NoError(dd.TableCreate(ctx, &tbl))
|
||||
}
|
||||
|
||||
t.Run("deep ident json", func(t *testing.T) {
|
||||
req = require.New(t)
|
||||
req.NoError(conn.store.Exec(ctx, conn.dialect.GOQU().Truncate(tbl.Ident)))
|
||||
|
||||
insert(`{"a": {"b": {"c": "match"}}}`)
|
||||
|
||||
req.Equal(1, countDeepJSON(req, "match"))
|
||||
req.Equal(0, countDeepJSON(req, "nope"))
|
||||
})
|
||||
|
||||
t.Run("json array contains", func(t *testing.T) {
|
||||
req = require.New(t)
|
||||
req.NoError(conn.store.Exec(ctx, conn.dialect.GOQU().Truncate(tbl.Ident)))
|
||||
|
||||
insert(`{"n": [1,2,3], "b": [true], "s": ["foo", "bar"], "nCheck0": 0, "bCheck0": false, "sCheck0": "baz", "nCheck1": 1, "bCheck1": true, "sCheck1": "foo"}`)
|
||||
|
||||
t.Log("Validating contains check with numeric value")
|
||||
req.Equal(1, countContains(req, numAttr, asJSON(1)))
|
||||
req.Equal(0, countContains(req, numAttr, asJSON(0)))
|
||||
|
||||
t.Log("Validating contains check with string value")
|
||||
req.Equal(1, countContains(req, textAttr, asJSON(`foo`)))
|
||||
req.Equal(1, countContains(req, textAttr, asJSON(`bar`)))
|
||||
req.Equal(0, countContains(req, textAttr, asJSON(`baz`)))
|
||||
|
||||
t.Log("Validating contains check with boolean value")
|
||||
req.Equal(1, countContains(req, boolAttr, asJSON(true)))
|
||||
req.Equal(0, countContains(req, boolAttr, asJSON(false)))
|
||||
|
||||
t.Log("Validating contains check with numeric field")
|
||||
req.Equal(1, countContains(req, numAttr, a2e(numCheck1Attr)))
|
||||
req.Equal(0, countContains(req, numAttr, a2e(numCheck0Attr)))
|
||||
|
||||
t.Log("Validating contains check with string field")
|
||||
req.Equal(1, countContains(req, textAttr, a2e(textCheck1Attr)))
|
||||
req.Equal(1, countContains(req, textAttr, a2e(textCheck1Attr)))
|
||||
req.Equal(0, countContains(req, textAttr, a2e(textCheck0Attr)))
|
||||
|
||||
t.Log("Validating contains check with boolean field")
|
||||
req.Equal(1, countContains(req, boolAttr, a2e(boolCheck1Attr)))
|
||||
req.Equal(0, countContains(req, boolAttr, a2e(boolCheck0Attr)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexManagement(t *testing.T) {
|
||||
t.Skip("not implemented")
|
||||
|
||||
//if conn.isSQLite {
|
||||
// t.Skip("sqlite does not support index management")
|
||||
//}
|
||||
//
|
||||
//var (
|
||||
// req = require.New(t)
|
||||
// dd = conn.store.DataDefiner
|
||||
// ii, err = dd.IndexLookup(ctx, ddl.PRIMARY_KEY, "users")
|
||||
//)
|
||||
//
|
||||
//req.NoError(err)
|
||||
//req.NotNil(ii)
|
||||
}
|
||||
128
server/store/adapters/rdbms/drivers/tests/main_test.go
Normal file
128
server/store/adapters/rdbms/drivers/tests/main_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/cli"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/mysql"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/postgres"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
"github.com/cortezaproject/corteza/server/tests/helpers"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
// test utility struct for testing various RDBMS implementations
|
||||
connectionInfo struct {
|
||||
dsn string
|
||||
|
||||
config *rdbms.ConnConfig
|
||||
store *rdbms.Store
|
||||
db *sqlx.DB
|
||||
dialect drivers.Dialect
|
||||
|
||||
// used for creating tables
|
||||
// @todo remove when we have proper DDL abstraction
|
||||
isSQLite bool
|
||||
isMySQL bool
|
||||
isPostgres bool
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
DB_DSN_DEFAULT = "sqlite3://file::memory:?cache=shared&mode=memory"
|
||||
)
|
||||
|
||||
var (
|
||||
// all connections, preloaded
|
||||
conn *connectionInfo
|
||||
|
||||
log = logger.MakeDebugLogger()
|
||||
|
||||
// all tests should use this context to pass
|
||||
// into DB calls to ensure commands are logged
|
||||
ctx = logger.ContextWithValue(context.Background(), log)
|
||||
)
|
||||
|
||||
func init() {
|
||||
helpers.RecursiveDotEnvLoad()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cli.HandleError(connect())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func connect() (err error) {
|
||||
var (
|
||||
is bool
|
||||
|
||||
s store.Storer
|
||||
)
|
||||
|
||||
conn = &connectionInfo{}
|
||||
|
||||
if conn.dsn, is = os.LookupEnv("DB_DSN"); !is {
|
||||
conn.dsn = DB_DSN_DEFAULT
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(conn.dsn, sqlite.SCHEMA):
|
||||
conn.config, err = sqlite.NewConfig(conn.dsn)
|
||||
conn.dialect = sqlite.Dialect()
|
||||
conn.isSQLite = true
|
||||
case strings.HasPrefix(conn.dsn, postgres.SCHEMA):
|
||||
conn.config, err = postgres.NewConfig(conn.dsn)
|
||||
conn.dialect = postgres.Dialect()
|
||||
conn.isPostgres = true
|
||||
case strings.HasPrefix(conn.dsn, mysql.SCHEMA):
|
||||
conn.config, err = mysql.NewConfig(conn.dsn)
|
||||
conn.dialect = mysql.Dialect()
|
||||
conn.isMySQL = true
|
||||
default:
|
||||
return fmt.Errorf("unsupported DB (dns: %q)", conn.dsn)
|
||||
}
|
||||
|
||||
// check each dsn by prefix and use newConnConfig from
|
||||
// appropriate driver
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("can not create config from %s: %w", conn.dsn, err)
|
||||
}
|
||||
|
||||
// need to connect in under 1 second
|
||||
toCtx, cfn := context.WithTimeout(ctx, time.Second*5)
|
||||
defer cfn()
|
||||
|
||||
s, err = store.Connect(toCtx, log, conn.dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can not connect to %q: %w", conn.config.DriverName, err)
|
||||
}
|
||||
|
||||
if conn.store, is = s.(*rdbms.Store); !is {
|
||||
return fmt.Errorf("can not extract *store.RDBMS from store")
|
||||
}
|
||||
|
||||
if conn.db, is = conn.store.DB.(*sqlx.DB); !is {
|
||||
return fmt.Errorf("can not extract *sqlx.DB from store")
|
||||
}
|
||||
|
||||
log.Debug("connected to " + conn.dsn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exec(q string) error {
|
||||
_, err := conn.db.ExecContext(ctx, q)
|
||||
return err
|
||||
|
||||
}
|
||||
333
server/store/adapters/rdbms/drivers/types.go
Normal file
333
server/store/adapters/rdbms/drivers/types.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
Type interface {
|
||||
MakeScanBuffer() any
|
||||
Decode(any) (any, bool, error)
|
||||
Encode(any) (driver.Value, error)
|
||||
}
|
||||
|
||||
// @todo makes sense to rethink this strategy
|
||||
// we do not need to have 1:1 type pairings with the pkg/dal
|
||||
// it makes sense to define a few most common ones here (in the rdbms/drivers)
|
||||
// and introduce per/driver exceptions to handle implementation specific things
|
||||
// like MySQLs lack of BOOLEAN that is replaced with TINYINT(1)
|
||||
|
||||
TypeID struct{ *dal.TypeID }
|
||||
TypeRef struct{ *dal.TypeRef }
|
||||
TypeTimestamp struct{ *dal.TypeTimestamp }
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
TypeDate struct{ *dal.TypeDate }
|
||||
TypeNumber struct{ *dal.TypeNumber }
|
||||
TypeText struct{ *dal.TypeText }
|
||||
TypeBoolean struct{ *dal.TypeBoolean }
|
||||
TypeEnum struct{ *dal.TypeEnum }
|
||||
TypeGeometry struct{ *dal.TypeGeometry }
|
||||
TypeJSON struct{ *dal.TypeJSON }
|
||||
TypeBlob struct{ *dal.TypeBlob }
|
||||
TypeUUID struct{ *dal.TypeUUID }
|
||||
)
|
||||
|
||||
// TypeWrap wraps type from data package
|
||||
func TypeWrap(dt dal.Type) Type {
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeID:
|
||||
return &TypeID{c}
|
||||
case *dal.TypeRef:
|
||||
return &TypeRef{c}
|
||||
case *dal.TypeTimestamp:
|
||||
return &TypeTimestamp{c}
|
||||
case *dal.TypeTime:
|
||||
return &TypeTime{c}
|
||||
case *dal.TypeDate:
|
||||
return &TypeDate{c}
|
||||
case *dal.TypeNumber:
|
||||
return &TypeNumber{c}
|
||||
case *dal.TypeText:
|
||||
return &TypeText{c}
|
||||
case *dal.TypeBoolean:
|
||||
return &TypeBoolean{c}
|
||||
case *dal.TypeEnum:
|
||||
return &TypeEnum{c}
|
||||
case *dal.TypeGeometry:
|
||||
return &TypeGeometry{c}
|
||||
case *dal.TypeJSON:
|
||||
return &TypeJSON{c}
|
||||
case *dal.TypeBlob:
|
||||
return &TypeBlob{c}
|
||||
case *dal.TypeUUID:
|
||||
return &TypeUUID{c}
|
||||
}
|
||||
|
||||
if dt == nil {
|
||||
panic(fmt.Sprintf("attribute type not set (nil)"))
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("type implementation missing: %s", dt.Type()))
|
||||
}
|
||||
|
||||
func (*TypeID) MakeScanBuffer() any { return new(ID) }
|
||||
func (*TypeRef) MakeScanBuffer() any { return new(ID) }
|
||||
func (*TypeTimestamp) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeDate) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
func (*TypeNumber) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeText) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeBoolean) MakeScanBuffer() any { return new(sql.NullBool) }
|
||||
func (*TypeEnum) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeGeometry) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
func (*TypeJSON) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
func (*TypeBlob) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
|
||||
func (t *TypeID) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*ID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for ID", raw)
|
||||
}
|
||||
|
||||
return dec.ID, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeID) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeRef) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*ID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Ref", raw)
|
||||
}
|
||||
|
||||
return dec.ID, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeRef) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Timestamp", raw)
|
||||
}
|
||||
|
||||
if dec.Valid {
|
||||
return dec.Time.UTC().Format(TimestampLayout(t.Timezone, t.Precision)), dec.Valid, nil
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) || val == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return parsed.Format(TimeLayout(t.Timezone, t.Precision)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeDate) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Date", raw)
|
||||
}
|
||||
|
||||
return dec.Time.Format(DateLayout), dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeDate) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeNumber) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Number", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeNumber) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeText) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Text", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeText) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeBoolean) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullBool)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Boolean", raw)
|
||||
}
|
||||
|
||||
return dec.Bool, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeBoolean) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return cast.ToBool(val), nil
|
||||
}
|
||||
|
||||
func (t *TypeEnum) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Enum", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeEnum) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeGeometry) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Geometry", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeGeometry) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeJSON) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for JSON", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeJSON) Encode(val any) (driver.Value, error) {
|
||||
switch c := val.(type) {
|
||||
case driver.Valuer:
|
||||
// does the value type know how to encode itself for the DB?
|
||||
return c.Value()
|
||||
|
||||
// These types are native to driver.Value
|
||||
case int64, float64, bool, []byte, string, time.Time:
|
||||
return c, nil
|
||||
|
||||
case json.Marshaler:
|
||||
// does the value type know how to encode itself as JSON?
|
||||
return c.MarshalJSON()
|
||||
|
||||
default:
|
||||
// Last resort - just encode with JSON pkg
|
||||
return json.Marshal(val)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TypeBlob) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Blob", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeBlob) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeUUID) MakeScanBuffer() any { return new(uuid.UUID) }
|
||||
|
||||
func (t *TypeUUID) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*uuid.UUID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for UUID", raw)
|
||||
}
|
||||
|
||||
return *dec, dec != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeUUID) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
const (
|
||||
DateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
func TimestampLayout(tz bool, precision int) string {
|
||||
return DateLayout + "T" + TimeLayout(tz, precision)
|
||||
}
|
||||
|
||||
func TimeLayout(tz bool, precision int) string {
|
||||
var layout = "15:04:05"
|
||||
if precision > 0 {
|
||||
layout += "." + strings.Repeat("9", precision)
|
||||
}
|
||||
|
||||
if tz {
|
||||
layout += "Z07:00"
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
69
server/store/adapters/rdbms/drivers/types_test.go
Normal file
69
server/store/adapters/rdbms/drivers/types_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTypeDecoders(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
name string
|
||||
inp any
|
||||
typ Type
|
||||
err error
|
||||
ok bool
|
||||
dec any
|
||||
}{
|
||||
{
|
||||
name: "time -tz -prec",
|
||||
inp: &sql.NullString{String: "06:06:06", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{}),
|
||||
ok: true,
|
||||
dec: "06:06:06",
|
||||
},
|
||||
{
|
||||
name: "time -tz +prec",
|
||||
inp: &sql.NullString{String: "06:06:06.321", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{Precision: 3}),
|
||||
ok: true,
|
||||
dec: "06:06:06.321",
|
||||
},
|
||||
{
|
||||
name: "time +tz +prec",
|
||||
inp: &sql.NullString{String: "06:06:06.321+04:00", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{Precision: 3, Timezone: true}),
|
||||
ok: true,
|
||||
dec: "06:06:06.321+04:00",
|
||||
},
|
||||
//{
|
||||
// name: "time ?tz ?prec",
|
||||
// inp: &sql.NullString{String: "06:06:06", Valid: true},
|
||||
// typ: TypeWrap(&data.TypeTime{Precision: 3, Timezone: true}),
|
||||
// ok: true,
|
||||
// dec: "06:06:06.321+04:00",
|
||||
//},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
dec, ok, err := c.typ.Decode(c.inp)
|
||||
if c.err == nil {
|
||||
req.NoError(err)
|
||||
} else {
|
||||
req.ErrorIs(err, c.err)
|
||||
}
|
||||
|
||||
req.Equal(c.ok, ok)
|
||||
req.EqualValues(c.dec, dec)
|
||||
})
|
||||
}
|
||||
}
|
||||
525
server/store/adapters/rdbms/filter.go
Normal file
525
server/store/adapters/rdbms/filter.go
Normal file
@@ -0,0 +1,525 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
|
||||
automationType "github.com/cortezaproject/corteza/server/automation/types"
|
||||
composeType "github.com/cortezaproject/corteza/server/compose/types"
|
||||
discoveryType "github.com/cortezaproject/corteza/server/discovery/types"
|
||||
"github.com/cortezaproject/corteza/server/federation/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
labelsType "github.com/cortezaproject/corteza/server/pkg/label/types"
|
||||
systemType "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
func DefaultFilters() (f *extendedFilters) {
|
||||
f = &extendedFilters{}
|
||||
|
||||
f.Actionlog = func(s *Store, f actionlog.Filter) (ee []goqu.Expression, _ actionlog.Filter, err error) {
|
||||
if ee, f, err = ActionlogFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure we always sort ID, descending
|
||||
if f.Sorting, err = filter.NewSorting("id DESC"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.BeforeActionID > 0 {
|
||||
ee = append(ee, goqu.C("id").Lt(f.BeforeActionID))
|
||||
}
|
||||
|
||||
if f.FromTimestamp != nil {
|
||||
ee = append(ee, goqu.C("ts").Gte(f.FromTimestamp))
|
||||
}
|
||||
|
||||
if f.ToTimestamp != nil {
|
||||
ee = append(ee, goqu.C("ts").Lte(f.ToTimestamp))
|
||||
}
|
||||
|
||||
if f.Limit == 0 || f.Limit > MaxLimit {
|
||||
f.Limit = MaxLimit
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.Application = func(s *Store, f systemType.ApplicationFilter) (ee []goqu.Expression, _ systemType.ApplicationFilter, err error) {
|
||||
if ee, f, err = ApplicationFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.FlaggedIDs) > 0 {
|
||||
ee = append(ee, goqu.C("id").In(f.FlaggedIDs))
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.AutomationSession = func(s *Store, f automationType.SessionFilter) (ee []goqu.Expression, _ automationType.SessionFilter, err error) {
|
||||
if ee, f, err = AutomationSessionFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Status) > 0 {
|
||||
ee = append(ee, goqu.C("status").In(f.Status))
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.ComposeAttachment = func(s *Store, f composeType.AttachmentFilter) (ee []goqu.Expression, _ composeType.AttachmentFilter, err error) {
|
||||
if ee, f, err = ComposeAttachmentFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch f.Kind {
|
||||
case composeType.PageAttachment:
|
||||
// @todo implement filtering by page
|
||||
if f.PageID > 0 {
|
||||
err = fmt.Errorf("filtering by pageID not implemented")
|
||||
return
|
||||
}
|
||||
|
||||
case composeType.IconAttachment:
|
||||
|
||||
case composeType.RecordAttachment:
|
||||
panic("@todo pending implementation")
|
||||
// query = query.
|
||||
// Join("compose_record_value AS v ON (v.ref = a.id)")
|
||||
//
|
||||
// if f.ModuleID > 0 {
|
||||
// query = query.
|
||||
// Join("compose_record AS r ON (r.id = v.record_id)").
|
||||
// Where(squirrel.Eq{"r.module_id": f.ModuleID})
|
||||
// }
|
||||
//
|
||||
// if f.RecordID > 0 {
|
||||
// query = query.Where(squirrel.Eq{"v.record_id": f.RecordID})
|
||||
// }
|
||||
//
|
||||
// if f.FieldName != "" {
|
||||
// query = query.Where(squirrel.Eq{"v.name": f.FieldName})
|
||||
// }
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("unsupported kind value")
|
||||
return
|
||||
}
|
||||
|
||||
if f.Filter != "" {
|
||||
err = fmt.Errorf("filtering by filter not implemented")
|
||||
return
|
||||
}
|
||||
|
||||
// Add a filter expression for deleted attachments
|
||||
if f.Deleted == filter.StateExcluded {
|
||||
ee = append(ee, goqu.C("deleted_at").IsNull())
|
||||
} else if f.Deleted == filter.StateExclusive {
|
||||
ee = append(ee, goqu.C("deleted_at").IsNotNull())
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.ComposePage = func(s *Store, f composeType.PageFilter) (ee []goqu.Expression, _ composeType.PageFilter, err error) {
|
||||
if ee, f, err = ComposePageFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.ParentID > 0 {
|
||||
ee = append(ee, goqu.C("self_id").Eq(f.ParentID))
|
||||
} else if f.Root {
|
||||
ee = append(ee, goqu.C("self_id").Eq(0))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.Label = func(s *Store, f labelsType.LabelFilter) (ee []goqu.Expression, _ labelsType.LabelFilter, err error) {
|
||||
if ee, f, err = LabelFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Filter) > 0 {
|
||||
keyConditions := make([]goqu.Expression, 0, len(f.Filter))
|
||||
|
||||
for k, v := range f.Filter {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
valueCol := goqu.C("value")
|
||||
|
||||
valExpr, err := s.Dialect.JsonExtractUnquote(valueCol, "value")
|
||||
if err != nil {
|
||||
return ee, f, err
|
||||
}
|
||||
valuesExpr, err := s.Dialect.JsonExtract(valueCol, "values")
|
||||
if err != nil {
|
||||
return ee, f, err
|
||||
}
|
||||
valueOrConditions := make([]goqu.Expression, 0, len(v))
|
||||
|
||||
for _, val := range v {
|
||||
singleValueMatch := exp.NewBooleanExpression(exp.EqOp, valExpr, val)
|
||||
escapedVal := strings.Replace(val, "'", "''", -1)
|
||||
jsonVal := exp.NewLiteralExpression("'" + "\"" + escapedVal + "\"" + "'")
|
||||
multiValueMatch, err := s.Dialect.JsonArrayContains(jsonVal, valuesExpr)
|
||||
if err != nil {
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
valueOrConditions = append(valueOrConditions, goqu.Or(
|
||||
singleValueMatch,
|
||||
multiValueMatch,
|
||||
))
|
||||
}
|
||||
keyConditions = append(keyConditions, goqu.And(
|
||||
goqu.C("name").Eq(k),
|
||||
goqu.Or(valueOrConditions...),
|
||||
))
|
||||
|
||||
}
|
||||
if len(keyConditions) > 0 {
|
||||
ee = append(ee, goqu.Or(keyConditions...))
|
||||
}
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.Reminder = func(s *Store, f systemType.ReminderFilter) (ee []goqu.Expression, _ systemType.ReminderFilter, err error) {
|
||||
if ee, f, err = ReminderFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.ExcludeDismissed {
|
||||
ee = append(ee, stateNilComparison(s.Dialect, "dismissed_at", filter.StateExcluded))
|
||||
}
|
||||
|
||||
if !f.IncludeDeleted {
|
||||
ee = append(ee, stateNilComparison(s.Dialect, "deleted_at", filter.StateExcluded))
|
||||
}
|
||||
|
||||
if f.ScheduledOnly {
|
||||
ee = append(ee, stateNilComparison(s.Dialect, "remind_at", filter.StateExclusive))
|
||||
}
|
||||
|
||||
if f.Resource != "" {
|
||||
ee = append(ee, goqu.C("resource").Like(f.Resource+"%"))
|
||||
}
|
||||
|
||||
if f.ScheduledFrom != nil {
|
||||
ee = append(ee, goqu.C("remind_at").Gte(f.ScheduledFrom.Format(time.RFC3339)))
|
||||
}
|
||||
if f.ScheduledUntil != nil {
|
||||
ee = append(ee, goqu.C("remind_at").Lte(f.ScheduledUntil.Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.ResourceTranslation = func(s *Store, f systemType.ResourceTranslationFilter) (ee []goqu.Expression, _ systemType.ResourceTranslationFilter, err error) {
|
||||
if ee, f, err = ResourceTranslationFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.ResourceType != "" {
|
||||
ee = append(ee, goqu.C("resource").Like(f.ResourceType+"%"))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.Role = func(s *Store, f systemType.RoleFilter) (ee []goqu.Expression, _ systemType.RoleFilter, err error) {
|
||||
if ee, f, err = RoleFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Resource) > 0 {
|
||||
memberships := roleMemberSelectQuery(s.Dialect.GOQU()).
|
||||
Select("rel_role").
|
||||
Where(goqu.C("rel_resource").In(f.Resource))
|
||||
|
||||
ee = append(ee, goqu.C("id").In(memberships))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.User = func(s *Store, f systemType.UserFilter) (ee []goqu.Expression, _ systemType.UserFilter, err error) {
|
||||
if ee, f, err = UserFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !f.AllKinds {
|
||||
ee = append(ee, goqu.C("kind").Eq(f.Kind))
|
||||
}
|
||||
|
||||
if len(f.RoleID) > 0 {
|
||||
const userResourcePrefix = "corteza::system:user/"
|
||||
members := roleMemberSelectQuery(s.Dialect.GOQU()).
|
||||
Select("rel_resource").
|
||||
Where(
|
||||
goqu.C("rel_role").In(f.RoleID),
|
||||
goqu.C("rel_resource").Like(userResourcePrefix+"%"),
|
||||
)
|
||||
|
||||
ee = append(ee, goqu.Func("concat",
|
||||
goqu.L("'"+userResourcePrefix+"'"),
|
||||
goqu.Cast(goqu.C("id"), "TEXT"),
|
||||
).In(members))
|
||||
}
|
||||
|
||||
if f.UserGroupID > 0 {
|
||||
ee = append(ee, goqu.C("rel_user_group").Eq(f.UserGroupID))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.SettingValue = func(s *Store, f systemType.SettingsFilter) (ee []goqu.Expression, _ systemType.SettingsFilter, err error) {
|
||||
if ee, f, err = SettingValueFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Prefix) > 0 {
|
||||
ee = append(ee, goqu.C("name").Like(f.Prefix+"%"))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.FederationExposedModule = func(s *Store, f types.ExposedModuleFilter) (ee []goqu.Expression, _ types.ExposedModuleFilter, err error) {
|
||||
if ee, f, err = FederationExposedModuleFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.LastSync > 0 {
|
||||
t := time.Unix(int64(f.LastSync), 0)
|
||||
|
||||
if !t.IsZero() {
|
||||
ts := t.UTC().Format(time.RFC3339)
|
||||
ee = append(ee, goqu.Or(
|
||||
goqu.C("updated_at").Gte(ts),
|
||||
goqu.C("created_at").Gte(ts),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.ResourceActivity = func(s *Store, f discoveryType.ResourceActivityFilter) (ee []goqu.Expression, _ discoveryType.ResourceActivityFilter, err error) {
|
||||
if ee, f, err = ResourceActivityFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Always sort by ID descending
|
||||
// query = query.OrderBy("id DESC")
|
||||
|
||||
if f.FromTimestamp != nil {
|
||||
ee = append(ee, goqu.C("ts").Gte(f.FromTimestamp))
|
||||
}
|
||||
|
||||
if f.ToTimestamp != nil {
|
||||
ee = append(ee, goqu.C("ts").Lte(f.ToTimestamp))
|
||||
}
|
||||
|
||||
if f.Limit == 0 || f.Limit > MaxLimit {
|
||||
f.Limit = MaxLimit
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.DataPrivacyRequest = func(s *Store, f systemType.DataPrivacyRequestFilter) (ee []goqu.Expression, _ systemType.DataPrivacyRequestFilter, err error) {
|
||||
if ee, f, err = DataPrivacyRequestFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Kind) > 0 {
|
||||
ee = append(ee, goqu.C("kind").In(f.Kind))
|
||||
}
|
||||
|
||||
if len(f.Status) > 0 {
|
||||
ee = append(ee, goqu.C("status").In(f.Status))
|
||||
}
|
||||
|
||||
if f.Limit == 0 || f.Limit > MaxLimit {
|
||||
f.Limit = MaxLimit
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.AutomationWorkflow = func(s *Store, f automationType.WorkflowFilter) (ee []goqu.Expression, _ automationType.WorkflowFilter, err error) {
|
||||
if ee, f, err = AutomationWorkflowFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.SubWorkflow != filter.StateInclusive {
|
||||
vattr := &dal.Attribute{Type: &dal.TypeBoolean{}}
|
||||
expr, _ := s.Dialect.JsonExtractUnquote(goqu.C("meta"), "subWorkflow")
|
||||
expr, _ = s.Dialect.AttributeCast(vattr, expr)
|
||||
|
||||
switch f.SubWorkflow {
|
||||
case filter.StateExcluded:
|
||||
ee = append(ee, goqu.Or(
|
||||
exp.NewBooleanExpression(exp.EqOp, expr, string(s.Dialect.DialectOptions().False)),
|
||||
exp.NewLiteralExpression("? IS NULL", expr),
|
||||
))
|
||||
case filter.StateExclusive:
|
||||
ee = append(ee,
|
||||
exp.NewBooleanExpression(exp.EqOp, expr, string(s.Dialect.DialectOptions().True)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return ee, f, err
|
||||
}
|
||||
|
||||
f.ApigwRoute = func(s *Store, f systemType.ApigwRouteFilter) (ee []goqu.Expression, _ systemType.ApigwRouteFilter, err error) {
|
||||
if ee, f, err = ApigwRouteFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ee = append(ee, goqu.C("endpoint").Like("%"+f.Endpoint+"%"))
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
f.ApigwFilter = func(s *Store, f systemType.ApigwFilterFilter) (ee []goqu.Expression, _ systemType.ApigwFilterFilter, err error) {
|
||||
if ee, f, err = ApigwFilterFilter(s.Dialect, f); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Ref) > 0 {
|
||||
ee = append(ee, goqu.C("ref").Eq(f.Ref))
|
||||
}
|
||||
|
||||
if len(f.Kind) > 0 {
|
||||
ee = append(ee, goqu.C("kind").Eq(f.Kind))
|
||||
}
|
||||
|
||||
return ee, f, nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func Order(sort filter.SortExprSet, sortables map[string]string) (oo []exp.OrderedExpression, err error) {
|
||||
return order(sort, sortables)
|
||||
}
|
||||
|
||||
func order(sort filter.SortExprSet, sortables map[string]string) (oo []exp.OrderedExpression, err error) {
|
||||
var (
|
||||
sortExpr goqu.Expression
|
||||
)
|
||||
|
||||
for _, s := range sort {
|
||||
sortExpr, err = generateSorting(sortables, s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.Descending {
|
||||
oo = append(oo, exp.NewOrderedExpression(sortExpr, exp.DescSortDir, exp.NoNullsSortType))
|
||||
} else {
|
||||
oo = append(oo, exp.NewOrderedExpression(sortExpr, exp.AscDir, exp.NoNullsSortType))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func stateNilComparison(d drivers.Dialect, lit string, fs filter.State) goqu.Expression {
|
||||
switch fs {
|
||||
case filter.StateExclusive:
|
||||
// only not-null values
|
||||
// @todo the NULL bit might be better of obtained from the dialect
|
||||
return exp.NewLiteralExpression(fmt.Sprintf("? IS NOT %s", string(d.DialectOptions().Null)), goqu.Literal(lit))
|
||||
|
||||
case filter.StateInclusive:
|
||||
// no filter
|
||||
return nil
|
||||
|
||||
default:
|
||||
// exclude all non-null values
|
||||
// @todo the NULL bit might be better of obtained from the dialect
|
||||
return exp.NewLiteralExpression(fmt.Sprintf("? IS %s", string(d.DialectOptions().Null)), goqu.Literal(lit))
|
||||
}
|
||||
}
|
||||
|
||||
func stateFalseComparison(d drivers.Dialect, lit string, fs filter.State) goqu.Expression {
|
||||
switch fs {
|
||||
case filter.StateExcluded:
|
||||
// only true
|
||||
return goqu.Literal(lit).Eq(string(d.DialectOptions().True))
|
||||
|
||||
case filter.StateExclusive:
|
||||
// only false
|
||||
return goqu.Literal(lit).Eq(string(d.DialectOptions().False))
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: Currently we have for support for MsSQL, MySql, PSQL, SQLite drivers,
|
||||
//
|
||||
// this changes is supported by all DB but we need to move to store.driver
|
||||
//
|
||||
// generateSorting verify and converts given sorting to literal if required
|
||||
func generateSorting(sortables map[string]string, s *filter.SortExpr) (out goqu.Expression, err error) {
|
||||
const COALESCE string = "coalesce"
|
||||
var (
|
||||
val string
|
||||
has bool
|
||||
|
||||
literalCols []interface{}
|
||||
|
||||
toLower = strings.ToLower
|
||||
hasSorting = func(column string) (string, bool) {
|
||||
v, ok := sortables[toLower(column)]
|
||||
return v, ok
|
||||
}
|
||||
)
|
||||
|
||||
if len(sortables) > 0 && s != nil {
|
||||
for _, col := range s.Columns() {
|
||||
val, has = hasSorting(col)
|
||||
if has {
|
||||
if out == nil {
|
||||
out = goqu.I(val)
|
||||
}
|
||||
literalCols = append(literalCols, goqu.I(val))
|
||||
} else {
|
||||
if len(s.Columns()) == 1 {
|
||||
err = fmt.Errorf("invalid column name: %s", col)
|
||||
} else {
|
||||
err = fmt.Errorf("column %q is not sortable in %s sorting", col, s.Columns())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if toLower(s.Modifier()) == COALESCE {
|
||||
if len(literalCols) > 0 {
|
||||
out = goqu.COALESCE(literalCols...)
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid sorting with %s modifier and columns: %s", s.Modifier(), s.Columns())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
89
server/store/adapters/rdbms/filter_test.go
Normal file
89
server/store/adapters/rdbms/filter_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_generateSorting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sort filter.SortExpr
|
||||
columns []string
|
||||
modifier string
|
||||
sortables map[string]string
|
||||
wantOut goqu.Expression
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single column sorting",
|
||||
sort: filter.SortExpr{Column: "createdat"},
|
||||
sortables: map[string]string{
|
||||
"createdat": "created_at",
|
||||
},
|
||||
wantOut: goqu.I("created_at"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path sorting",
|
||||
sort: filter.SortExpr{Column: "user.createdat"},
|
||||
sortables: map[string]string{
|
||||
"user.createdat": "user.created_at",
|
||||
},
|
||||
wantOut: goqu.I("user.created_at"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "coalesce single column sorting",
|
||||
sort: filter.SortExpr{},
|
||||
columns: []string{"createdat"},
|
||||
modifier: "coalesce",
|
||||
sortables: map[string]string{
|
||||
"createdat": "created_at",
|
||||
"updatedat": "updated_at",
|
||||
},
|
||||
wantOut: goqu.COALESCE(goqu.I("created_at")),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "coalesce multiple column sorting",
|
||||
sort: filter.SortExpr{},
|
||||
columns: []string{"createdat", "updatedat"},
|
||||
modifier: "coalesce",
|
||||
sortables: map[string]string{
|
||||
"createdat": "created_at",
|
||||
"updatedat": "updated_at",
|
||||
},
|
||||
wantOut: goqu.COALESCE(goqu.I("created_at"), goqu.I("updated_at")),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid coalesce multiple column sorting",
|
||||
sort: filter.SortExpr{},
|
||||
columns: []string{"createdat", "deletedat"},
|
||||
modifier: "coalesce",
|
||||
sortables: map[string]string{
|
||||
"createdat": "created_at",
|
||||
"updatedat": "updated_at",
|
||||
},
|
||||
wantOut: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.sort.SetColumns(tt.columns...)
|
||||
_ = tt.sort.SetModifier(tt.modifier)
|
||||
gotOut, err := generateSorting(tt.sortables, &tt.sort)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateSorting() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(gotOut, tt.wantOut) {
|
||||
t.Errorf("generateSorting() gotOut = %v, want %v", gotOut, tt.wantOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1501
server/store/adapters/rdbms/filters.gen.go
generated
Normal file
1501
server/store/adapters/rdbms/filters.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
server/store/adapters/rdbms/instrumentation/README.adoc
Normal file
23
server/store/adapters/rdbms/instrumentation/README.adoc
Normal file
@@ -0,0 +1,23 @@
|
||||
= RDBMS Store instrumentation & debug
|
||||
|
||||
This package provides wrap around `database/sql/driver` that logs and measures db activities that pass through store.
|
||||
|
||||
== Current capabilities
|
||||
|
||||
=== Debug wrapper
|
||||
|
||||
Logging of all activities (connections, queries, execs, transactions) and activity duration measures.
|
||||
This feature should be used in development env and disabled in production.
|
||||
|
||||
.To enable debugging, use extra * parameter for DSN:
|
||||
====
|
||||
DB_DSN="postgres+debug://crust:crust@localhost:5432/crust?sslmode=disable&"
|
||||
====
|
||||
|
||||
In previous versions similar functionality was enabled by setting `DB_LOGGER` environmental variable.
|
||||
This is no longer supported because of migration to "store" architecture and ability to selectively enable this
|
||||
only for specific connections.
|
||||
|
||||
== Plans
|
||||
|
||||
This package will support proper instrumentation, tracing and metrics via OpenTracing/OpenTelemetry.
|
||||
272
server/store/adapters/rdbms/instrumentation/debug.go
Normal file
272
server/store/adapters/rdbms/instrumentation/debug.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package instrumentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/ngrok/sqlmw"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
// Debug instrumentation wrapper for sql driver
|
||||
//
|
||||
// All calls (with context) are wrapped (using github.com/ngrok/sqlmw package)
|
||||
// and that allows us to log all traffic going in and out of the database
|
||||
debug struct {
|
||||
sqlmw.NullInterceptor
|
||||
fallback *zap.Logger
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
now = func() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
)
|
||||
|
||||
func Debug() *debug {
|
||||
return &debug{fallback: zap.NewNop()}
|
||||
}
|
||||
|
||||
// Returns logger from context with additional options
|
||||
//
|
||||
// fn tries checks context or uses fallback logger
|
||||
func (ld debug) log(ctx context.Context) *zap.Logger {
|
||||
return logger.
|
||||
ContextValue(ctx, logger.Default(), ld.fallback).
|
||||
WithOptions(
|
||||
// get all the way back to RDBMS layer
|
||||
// @todo not sure that all functions below
|
||||
// have the same stack trace, might
|
||||
// need adjustment on individual fn bellow
|
||||
zap.AddCallerSkip(9),
|
||||
)
|
||||
}
|
||||
|
||||
func (debug) argToZapFields(args []driver.NamedValue) []zap.Field {
|
||||
var (
|
||||
name string
|
||||
out = make([]zap.Field, len(args))
|
||||
)
|
||||
for i := range args {
|
||||
name = "arg."
|
||||
if args[i].Name != "" {
|
||||
name += args[i].Name
|
||||
} else {
|
||||
name += fmt.Sprintf("%d", args[i].Ordinal)
|
||||
}
|
||||
|
||||
// Catch time to ensure proper format
|
||||
switch args[i].Value.(type) {
|
||||
case time.Time:
|
||||
out[i] = zap.String(name, args[i].Value.(time.Time).Format(time.RFC3339))
|
||||
case *time.Time:
|
||||
out[i] = zap.String(name, (args[i].Value.(*time.Time)).Format(time.RFC3339))
|
||||
default:
|
||||
out[i] = zap.Any(name, args[i].Value)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (ld debug) ConnBeginTx(ctx context.Context, conn driver.ConnBeginTx, txOpts driver.TxOptions) (driver.Tx, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
tx, err = conn.BeginTx(ctx, txOpts)
|
||||
log = ld.log(ctx).With(zap.Duration("duration", time.Since(startedAt)))
|
||||
message = "conn/begin"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (ld debug) ConnPrepareContext(ctx context.Context, conn driver.ConnPrepareContext, query string) (driver.Stmt, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
statement, err = conn.PrepareContext(ctx, query)
|
||||
log = ld.log(ctx).With(
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
zap.Any("query", query),
|
||||
)
|
||||
message = "conn/prepare"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return statement, nil
|
||||
}
|
||||
|
||||
func (ld debug) ConnPing(ctx context.Context, conn driver.Pinger) error {
|
||||
var (
|
||||
startedAt = now()
|
||||
err = conn.Ping(ctx)
|
||||
log = ld.log(ctx).With(zap.Duration("duration", time.Since(startedAt)))
|
||||
message = "conn/ping"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld debug) ConnExecContext(ctx context.Context, conn driver.ExecerContext, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
result, err = conn.ExecContext(ctx, query, args)
|
||||
log = ld.log(ctx).With(
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
zap.Any("query", query),
|
||||
).With(ld.argToZapFields(args)...)
|
||||
message = "conn/exec"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, driver.ErrSkip) {
|
||||
// Not actually an error; just informing caller
|
||||
// that it should take an alternative path
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ld debug) ConnQueryContext(ctx context.Context, conn driver.QueryerContext, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
rows, err = conn.QueryContext(ctx, query, args)
|
||||
log = ld.log(ctx).With(
|
||||
zap.String("query", query),
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
).With(ld.argToZapFields(args)...)
|
||||
message = "statement/query"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, driver.ErrSkip) {
|
||||
// Not actually an error; just informing caller
|
||||
// that it should take an alternative path
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (ld debug) StmtExecContext(ctx context.Context, stmt driver.StmtExecContext, _ string, args []driver.NamedValue) (driver.Result, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
result, err = stmt.ExecContext(ctx, args)
|
||||
log = ld.log(ctx).With(
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
).With(ld.argToZapFields(args)...)
|
||||
message = "statement/exec"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ld debug) StmtQueryContext(ctx context.Context, stmt driver.StmtQueryContext, _ string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
var (
|
||||
startedAt = now()
|
||||
rows, err = stmt.QueryContext(ctx, args)
|
||||
log = ld.log(ctx).With(
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
).With(ld.argToZapFields(args)...)
|
||||
message = "statement/query"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (ld debug) StmtClose(ctx context.Context, stmt driver.Stmt) error {
|
||||
var (
|
||||
startedAt = now()
|
||||
err = stmt.Close()
|
||||
log = ld.log(ctx).With(zap.Duration("duration", time.Since(startedAt)))
|
||||
message = "statement/close"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld debug) TxCommit(ctx context.Context, tx driver.Tx) error {
|
||||
var (
|
||||
startedAt = now()
|
||||
err = tx.Commit()
|
||||
log = ld.log(ctx).With(zap.Duration("duration", time.Since(startedAt)))
|
||||
message = "tx/commit"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld debug) TxRollback(ctx context.Context, tx driver.Tx) error {
|
||||
var (
|
||||
startedAt = now()
|
||||
err = tx.Rollback()
|
||||
log = ld.log(ctx).With(zap.Duration("duration", time.Since(startedAt)))
|
||||
message = "tx/rollback"
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error(message, zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
log.Warn(message)
|
||||
return nil
|
||||
}
|
||||
21
server/store/adapters/rdbms/log.go
Normal file
21
server/store/adapters/rdbms/log.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// log() returns named logger with caller skip and stacktrace set to Fatal
|
||||
//
|
||||
// It checks the given context for logger before falling back to one set on the store
|
||||
func (s Store) log(ctx context.Context) *zap.Logger {
|
||||
return logger.ContextValue(ctx, s.Logger, logger.Default(), zap.NewNop()).
|
||||
Named("store").
|
||||
WithOptions(zap.AddCallerSkip(2))
|
||||
}
|
||||
|
||||
func (s Store) SetLogger(logger *zap.Logger) {
|
||||
s.Logger = logger
|
||||
}
|
||||
105
server/store/adapters/rdbms/ql/converter.go
Normal file
105
server/store/adapters/rdbms/ql/converter.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package ql
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
refHandler func(*ql.ASTNode, ...exp.Expression) (exp.Expression, error)
|
||||
symHandler func(*ql.ASTNode) (exp.Expression, error)
|
||||
valHandler func(*ql.ASTNode) (exp.Expression, error)
|
||||
|
||||
// converts query and AST from the parsed query into goqu expression
|
||||
// that can be used in the SQL
|
||||
converter struct {
|
||||
parser *ql.Parser
|
||||
refHandler refHandler
|
||||
symHandler symHandler
|
||||
valHandler valHandler
|
||||
}
|
||||
|
||||
op func(*converter)
|
||||
)
|
||||
|
||||
// Converter initializes new converter
|
||||
func Converter(oo ...op) *converter {
|
||||
c := &converter{
|
||||
parser: ql.NewParser(),
|
||||
refHandler: DefaultRefHandler,
|
||||
symHandler: DefaultSymbolHandler,
|
||||
valHandler: DefaultValueHandler,
|
||||
}
|
||||
|
||||
for _, o := range oo {
|
||||
o(c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// RefHandler sets custom ref handler
|
||||
func RefHandler(h refHandler) op {
|
||||
return func(c *converter) {
|
||||
c.refHandler = h
|
||||
}
|
||||
}
|
||||
|
||||
// SymHandler sets custom symbol handler
|
||||
func SymHandler(h symHandler) op {
|
||||
return func(c *converter) {
|
||||
c.symHandler = h
|
||||
}
|
||||
}
|
||||
|
||||
// ValHandler sets custom value handler
|
||||
func ValHandler(h valHandler) op {
|
||||
return func(c *converter) {
|
||||
c.valHandler = h
|
||||
}
|
||||
}
|
||||
|
||||
func (c *converter) Parse(q string) (exp.Expression, error) {
|
||||
n, err := c.parser.Parse(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.Convert(n)
|
||||
}
|
||||
|
||||
// Convert recursively walks through AST and converts the entire tree into expression
|
||||
// that can be used in goqu SQL builder
|
||||
func (c *converter) Convert(n *ql.ASTNode) (_ exp.Expression, err error) {
|
||||
switch {
|
||||
case n.Symbol != "":
|
||||
return c.symHandler(n)
|
||||
case n.Value != nil:
|
||||
return c.valHandler(n)
|
||||
}
|
||||
|
||||
args := make([]exp.Expression, len(n.Args))
|
||||
for i, a := range n.Args {
|
||||
args[i], err = c.Convert(a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return c.refHandler(n, args...)
|
||||
}
|
||||
|
||||
// DefaultRefHandler converts ref from the AST node using ref2exp
|
||||
func DefaultRefHandler(n *ql.ASTNode, args ...exp.Expression) (exp.Expression, error) {
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
// DefaultSymbolHandler parses symbol from the AST node into an identifier
|
||||
func DefaultSymbolHandler(n *ql.ASTNode) (exp.Expression, error) {
|
||||
return exp.ParseIdentifier(n.Symbol), nil
|
||||
}
|
||||
|
||||
// DefaultValueHandler converts node into placeholder and a new attribute
|
||||
func DefaultValueHandler(n *ql.ASTNode) (exp.Expression, error) {
|
||||
return exp.NewLiteralExpression("?", n.Value.V.Get()), nil
|
||||
}
|
||||
100
server/store/adapters/rdbms/ql/converter_test.go
Normal file
100
server/store/adapters/rdbms/ql/converter_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConverter(t *testing.T) {
|
||||
const WHERE = " WHERE "
|
||||
var (
|
||||
base = goqu.Dialect("sqlite").Select().Prepared(true)
|
||||
conv = Converter()
|
||||
|
||||
cases = []struct {
|
||||
qry string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
qry: `foo = 1`,
|
||||
sql: `("foo" = ?)`,
|
||||
args: []any{int64(1)},
|
||||
},
|
||||
{
|
||||
qry: `foo = 1 AND bar = baz`,
|
||||
sql: `(("foo" = ?) AND ("bar" = "baz"))`,
|
||||
args: []any{int64(1)},
|
||||
},
|
||||
{
|
||||
qry: `foo = 1 OR (bar = 'baz')`,
|
||||
sql: `(("foo" = ?) OR ("bar" = ?))`,
|
||||
args: []any{int64(1), "baz"},
|
||||
},
|
||||
{
|
||||
qry: `foo is not null`,
|
||||
sql: `("foo" IS NOT NULL)`,
|
||||
args: []any{},
|
||||
},
|
||||
{
|
||||
qry: `!foo`,
|
||||
sql: `(NOT "foo")`,
|
||||
args: []any{},
|
||||
},
|
||||
{
|
||||
qry: `one + 2 / 3 * 4 < 10`,
|
||||
sql: `("one" + ? / ? * ? < ?)`,
|
||||
args: []any{int64(2), int64(3), int64(4), int64(10)},
|
||||
},
|
||||
{
|
||||
qry: `concat('foo', 'bar')`,
|
||||
sql: `CONCAT(?, ?)`,
|
||||
args: []any{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
qry: `quarter('2022-07-21')`,
|
||||
sql: `QUARTER(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `year('2022-07-21')`,
|
||||
sql: `YEAR(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `month('2022-07-21')`,
|
||||
sql: `MONTH(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date('2022-07-21')`,
|
||||
sql: `DAY(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.qry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
t.Log(c.qry)
|
||||
ee, err := conv.Parse(c.qry)
|
||||
req.NoError(err)
|
||||
|
||||
sql, args, err := base.Where(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
|
||||
p := strings.Index(sql, WHERE)
|
||||
req.Positive(p)
|
||||
|
||||
sql = sql[p+len(WHERE):]
|
||||
|
||||
req.Equal(c.sql, sql)
|
||||
req.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
11
server/store/adapters/rdbms/ql/ql.go
Normal file
11
server/store/adapters/rdbms/ql/ql.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package ql
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
)
|
||||
|
||||
// Aliasing types from the pkg/ql to simplify imports
|
||||
|
||||
type (
|
||||
ASTNode = ql.ASTNode
|
||||
)
|
||||
335
server/store/adapters/rdbms/ql/ref.go
Normal file
335
server/store/adapters/rdbms/ql/ref.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package ql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
ExprHandlerMap map[string]*ExprHandler
|
||||
|
||||
ExprHandler struct {
|
||||
Handler func(...exp.Expression) exp.Expression
|
||||
HandlerE func(...exp.Expression) (exp.Expression, error)
|
||||
RangeHandler func(exp.Expression, exp.RangeVal) exp.Expression
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ExprHandlerMap{
|
||||
// keywords
|
||||
"null": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("NULL")
|
||||
},
|
||||
},
|
||||
"nnull": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("NOT NULL")
|
||||
},
|
||||
},
|
||||
|
||||
// operators
|
||||
"not": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(NOT ?)", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
// - bool
|
||||
"and": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewExpressionList(exp.AndType, args...)
|
||||
},
|
||||
},
|
||||
"or": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewExpressionList(exp.OrType, args...)
|
||||
},
|
||||
},
|
||||
|
||||
// - comp.
|
||||
"eq": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.EqOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"ne": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.NeqOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"lt": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.LtOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"le": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.LteOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"gt": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.GtOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"ge": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.GteOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"in": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.InOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"nin": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.NotInOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// - math
|
||||
"add": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("? + ?", args[0], args[1])
|
||||
},
|
||||
},
|
||||
"sub": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("? - ?", args[0], args[1])
|
||||
},
|
||||
},
|
||||
"mult": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// Handling COUNT(*) scenario:
|
||||
// ql parser interprets * as a multiplier
|
||||
//
|
||||
// in situations where there are 0 arguments we'll just
|
||||
// return star
|
||||
if len(args) == 0 {
|
||||
return exp.Star()
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("? * ?", args[0], args[1])
|
||||
},
|
||||
},
|
||||
"div": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("? / ?", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// - strings
|
||||
"concat": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = args[a]
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("CONCAT", aa...)
|
||||
},
|
||||
},
|
||||
|
||||
"interval": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
|
||||
// The problem here is similar to the one with PgSQL... but more complicated...
|
||||
// The interval duration identifiers are keywords and can't be bound via value placeholders.
|
||||
// This forces us to interpolate the thing when building the literal expression below.
|
||||
//
|
||||
// Since goqu doesn't let me get the raw value of the expression... I was forced
|
||||
// to do this calamity
|
||||
_, aa, err := goqu.Select(args[0]).ToSQL()
|
||||
if err != nil {
|
||||
// This error should never occur
|
||||
panic(err)
|
||||
}
|
||||
intv := aa[0].(string)
|
||||
|
||||
return exp.NewLiteralExpression(fmt.Sprintf("INTERVAL ? %s", intv), args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"date_add": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE_ADD", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"date_sub": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE_SUB", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// @todo better negation?
|
||||
"like": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.ILikeOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
"nlike": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.NotILikeOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// range operation
|
||||
"between": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewRangeExpression(exp.BetweenOp, args[0], exp.NewRangeVal(args[1], args[2]))
|
||||
},
|
||||
},
|
||||
"nbetween": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewRangeExpression(exp.NotBetweenOp, args[0], exp.NewRangeVal(args[1], args[2]))
|
||||
},
|
||||
},
|
||||
|
||||
"is": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.IsOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"nis": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewBooleanExpression(exp.IsNotOp, args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"group": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(?)", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
// - filtering
|
||||
"date_format": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE_FORMAT", args[0], args[1])
|
||||
},
|
||||
},
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("NOW")
|
||||
},
|
||||
},
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("QUARTER", args[0])
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("YEAR", args[0])
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("MONTH", args[0])
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DAY", args[0])
|
||||
},
|
||||
},
|
||||
"week": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("WEEK", args[0])
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE", args[0])
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("TIMESTAMP(?)", args[0])
|
||||
},
|
||||
},
|
||||
"time": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("TIME(?)", args[0])
|
||||
},
|
||||
},
|
||||
"count": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
var arg exp.Expression = exp.NewLiteralExpression("*")
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("COUNT", arg)
|
||||
},
|
||||
},
|
||||
"sum": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("SUM", args[0])
|
||||
},
|
||||
},
|
||||
"avg": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("AVG", args[0])
|
||||
},
|
||||
},
|
||||
"min": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("MIN", args[0])
|
||||
},
|
||||
},
|
||||
"max": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("MAX", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"std": {
|
||||
HandlerE: func(args ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("STD function not supported on this database")
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func (ee ExprHandlerMap) ExprHandlers() (out ExprHandlerMap) {
|
||||
// Set the default values; make sure to copy the global map
|
||||
out = make(map[string]*ExprHandler, len(ref2exp))
|
||||
for name, expr := range ref2exp {
|
||||
out[name] = expr
|
||||
}
|
||||
|
||||
// Overwrite with customs
|
||||
for name, expr := range ee {
|
||||
out[name] = expr
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ee ExprHandlerMap) RefHandler(n *ql.ASTNode, args ...exp.Expression) (exp.Expression, error) {
|
||||
r := strings.ToLower(n.Ref)
|
||||
|
||||
if ee[r] == nil {
|
||||
return nil, fmt.Errorf("unknown ref %q", r)
|
||||
}
|
||||
|
||||
if ee[r].Handler != nil && ee[r].HandlerE != nil {
|
||||
panic("invalid ql ref definition: both Handler and HandlerE are set")
|
||||
}
|
||||
|
||||
if ee[r].Handler != nil {
|
||||
return ee[r].Handler(args...), nil
|
||||
}
|
||||
|
||||
return ee[r].HandlerE(args...)
|
||||
}
|
||||
210
server/store/adapters/rdbms/rdbms_store.go
Normal file
210
server/store/adapters/rdbms/rdbms_store.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exec"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
sqlizer interface {
|
||||
ToSQL() (string, []interface{}, error)
|
||||
}
|
||||
|
||||
scanner interface {
|
||||
Scan(...interface{}) error
|
||||
}
|
||||
|
||||
txRetryOnErrHandler func(int, error) bool
|
||||
|
||||
Functions struct {
|
||||
// returns lower case text
|
||||
LOWER func(interface{}) exp.SQLFunctionExpression
|
||||
|
||||
// returns date part of the input (YYYY-MM-DD)
|
||||
DATE func(interface{}) exp.SQLFunctionExpression
|
||||
}
|
||||
|
||||
Store struct {
|
||||
DB sqlx.ExtContext
|
||||
|
||||
// DAL connection
|
||||
DAL dal.Connection
|
||||
|
||||
// Logger for connection
|
||||
Logger *zap.Logger
|
||||
|
||||
// data definer interface use for schema information lookups and modification
|
||||
DataDefiner ddl.DataDefiner
|
||||
|
||||
Dialect drivers.Dialect
|
||||
|
||||
// set to -1 to disable transactions
|
||||
TxRetryLimit int
|
||||
|
||||
// TxRetryErrHandler should return true if transaction should be retried
|
||||
//
|
||||
// Because retry algorithm varies between concrete rdbms implementations
|
||||
//
|
||||
// Handler must return true if failed transaction should be replied
|
||||
// and false if we're safe to terminate it
|
||||
TxRetryErrHandler txRetryOnErrHandler
|
||||
|
||||
ErrorHandler store.ErrorHandler
|
||||
|
||||
Functions *Functions
|
||||
|
||||
// additional (per-resource-type) filters used when searching
|
||||
// these filters can modify expression used for querying the database
|
||||
Filters *extendedFilters
|
||||
|
||||
Ping func(ctx context.Context) error
|
||||
}
|
||||
)
|
||||
|
||||
// SetDefaults fn sets all defaults that need to be set
|
||||
func (s *Store) SetDefaults() {
|
||||
if s.TxRetryErrHandler == nil {
|
||||
s.TxRetryErrHandler = func(i int, err error) bool { return false }
|
||||
}
|
||||
|
||||
if s.ErrorHandler == nil {
|
||||
s.ErrorHandler = func(err error) error { return err }
|
||||
}
|
||||
|
||||
if s.Functions == nil {
|
||||
s.Functions = DefaultFunctions()
|
||||
}
|
||||
|
||||
if s.Filters == nil {
|
||||
s.Filters = DefaultFilters()
|
||||
}
|
||||
}
|
||||
|
||||
// WithTx spins up new store instance with transaction
|
||||
func (s *Store) withTx(tx sqlx.ExtContext) *Store {
|
||||
return &Store{
|
||||
DB: tx,
|
||||
|
||||
Logger: s.Logger,
|
||||
DataDefiner: s.DataDefiner,
|
||||
Dialect: s.Dialect,
|
||||
TxRetryErrHandler: s.TxRetryErrHandler,
|
||||
ErrorHandler: s.ErrorHandler,
|
||||
Functions: s.Functions,
|
||||
Filters: s.Filters,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store) Healthcheck(ctx context.Context) error {
|
||||
if s.Ping == nil {
|
||||
return errors.Internal("no store ping function defined")
|
||||
}
|
||||
|
||||
return s.Ping(ctx)
|
||||
}
|
||||
|
||||
// Exec executes the SQL and drops the result
|
||||
func (s Store) Exec(ctx context.Context, q sqlizer) error {
|
||||
_, err := s.exec(ctx, q)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecR executes the SQL and returns the result
|
||||
func (s Store) ExecR(ctx context.Context, q sqlizer) (rs sql.Result, _ error) {
|
||||
return s.exec(ctx, q)
|
||||
}
|
||||
|
||||
func (s Store) exec(ctx context.Context, q sqlizer) (rs sql.Result, _ error) {
|
||||
var (
|
||||
query, args, err = q.ToSQL()
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return rs, fmt.Errorf("could not build query: %w", err)
|
||||
}
|
||||
|
||||
rs, err = s.DB.ExecContext(ctx, query, args...)
|
||||
return rs, store.HandleError(err, s.ErrorHandler)
|
||||
}
|
||||
|
||||
func (s Store) Query(ctx context.Context, q sqlizer) (*sql.Rows, error) {
|
||||
var (
|
||||
rr *sql.Rows
|
||||
|
||||
query, args, err = q.ToSQL()
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not build query: %w", err)
|
||||
}
|
||||
|
||||
rr, err = s.DB.QueryContext(ctx, query, args...)
|
||||
if err = store.HandleError(err, s.ErrorHandler); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
func (s Store) QueryOne(ctx context.Context, q sqlizer, dst interface{}) (err error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
)
|
||||
|
||||
rows, err = s.Query(ctx, q)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
closeError := rows.Close()
|
||||
if err == nil {
|
||||
// return error from close
|
||||
err = closeError
|
||||
}
|
||||
}()
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
return store.ErrNotFound.Stack(1)
|
||||
}
|
||||
|
||||
return exec.NewScanner(rows).ScanStruct(dst)
|
||||
}
|
||||
|
||||
// ToDalConn uses store as DAL connection
|
||||
func (s Store) ToDalConn() dal.Connection {
|
||||
return s.DAL
|
||||
}
|
||||
|
||||
func DefaultFunctions() *Functions {
|
||||
f := &Functions{}
|
||||
|
||||
if f.LOWER == nil {
|
||||
f.LOWER = func(value interface{}) exp.SQLFunctionExpression {
|
||||
return goqu.Func("LOWER", value)
|
||||
}
|
||||
}
|
||||
|
||||
if f.DATE == nil {
|
||||
f.DATE = func(value interface{}) exp.SQLFunctionExpression {
|
||||
return goqu.Func("DATE", value)
|
||||
}
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
2
server/store/adapters/rdbms/rdbms_store_test.go
Normal file
2
server/store/adapters/rdbms/rdbms_store_test.go
Normal file
@@ -0,0 +1,2 @@
|
||||
package rdbms
|
||||
|
||||
100
server/store/adapters/rdbms/tx.go
Normal file
100
server/store/adapters/rdbms/tx.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
dbTransactionMaker interface {
|
||||
BeginTxx(ctx context.Context, opts *sql.TxOptions) (*sqlx.Tx, error)
|
||||
}
|
||||
)
|
||||
|
||||
func (s *Store) Tx(ctx context.Context, fn func(context.Context, store.Storer) error) error {
|
||||
if s.TxRetryLimit < 0 {
|
||||
return fn(ctx, s)
|
||||
}
|
||||
|
||||
return txHandler(ctx, s.DB, s.TxRetryLimit, s.TxRetryErrHandler, func(ctx context.Context, tx sqlx.ExtContext) error {
|
||||
return fn(ctx, s.withTx(tx))
|
||||
})
|
||||
}
|
||||
|
||||
// tx begins a new db transaction and handles retries when possible
|
||||
//
|
||||
// It utilizes configured transaction error handlers and max-retry limits
|
||||
// to determine if and how many times transaction should be retried
|
||||
//
|
||||
func txHandler(ctx context.Context, dbc interface{}, max int, reh txRetryOnErrHandler, task func(context.Context, sqlx.ExtContext) error) error {
|
||||
var (
|
||||
lastTaskErr error
|
||||
err error
|
||||
db *sqlx.DB
|
||||
tx *sqlx.Tx
|
||||
try = 1
|
||||
)
|
||||
|
||||
switch dbc.(type) {
|
||||
case dbTransactionMaker:
|
||||
// we can make a transaction!
|
||||
db = dbc.(*sqlx.DB)
|
||||
case sqlx.ExtContext:
|
||||
// Already in a transaction, run the given task and finish
|
||||
return task(ctx, dbc.(sqlx.ExtContext))
|
||||
default:
|
||||
return fmt.Errorf("could not use the db connection for transaction")
|
||||
}
|
||||
|
||||
for {
|
||||
try++
|
||||
|
||||
// Start transaction
|
||||
tx, err = db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lastTaskErr = task(ctx, tx); lastTaskErr == nil {
|
||||
// Task completed successfully
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// No matter the cause of the error, rollback the transaction
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return fmt.Errorf("failed to rollback transaction (tries: %d) on error %v: %w", try, lastTaskErr, rollbackErr)
|
||||
}
|
||||
|
||||
if errors.IsAny(lastTaskErr) {
|
||||
// not a store-internal/transaction error and can be returned right away,
|
||||
// no need to re-run the transaction
|
||||
return lastTaskErr
|
||||
}
|
||||
|
||||
// Call the configured transaction retry error handler
|
||||
// if this particular error should be retried or not
|
||||
//
|
||||
// We cannot generalize here because different store implementation have
|
||||
// different errors; we need to act accordingly
|
||||
if reh != nil && !reh(try, lastTaskErr) {
|
||||
return fmt.Errorf("failed to complete transaction: %w", lastTaskErr)
|
||||
}
|
||||
|
||||
// tx error handlers can take current number of tries into account and
|
||||
// break the retry-loop earlier, but that might not be always the case
|
||||
//
|
||||
// We'll check the configured and hard-limit maximums
|
||||
if try >= max || try >= TxRetryHardLimit {
|
||||
return fmt.Errorf("failed to perform transaction (tries: %d), last error: %w", try, lastTaskErr)
|
||||
}
|
||||
|
||||
// Sleep (with a bit of kickback) before doing next retry
|
||||
time.Sleep(50 * time.Duration(try*50))
|
||||
}
|
||||
}
|
||||
209
server/store/adapters/rdbms/upgrade.go
Normal file
209
server/store/adapters/rdbms/upgrade.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package rdbms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
automationModels "github.com/cortezaproject/corteza/server/automation/model"
|
||||
composeModels "github.com/cortezaproject/corteza/server/compose/model"
|
||||
federationModels "github.com/cortezaproject/corteza/server/federation/model"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
systemModels "github.com/cortezaproject/corteza/server/system/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Store) Upgrade(ctx context.Context) (err error) {
|
||||
for _, fix := range fixesPre {
|
||||
if err = fix(ctx, s); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = createTablesFromModels(
|
||||
ctx,
|
||||
s.log(ctx),
|
||||
s.DataDefiner,
|
||||
systemModels.Models(),
|
||||
composeModels.Models(),
|
||||
automationModels.Models(),
|
||||
federationModels.Models(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fix := range fixesPost {
|
||||
if err = fix(ctx, s); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func createTablesFromModels(ctx context.Context, log *zap.Logger, dd ddl.DataDefiner, sets ...dal.ModelSet) (err error) {
|
||||
var (
|
||||
tbl *ddl.Table
|
||||
)
|
||||
|
||||
for _, mm := range sets {
|
||||
for _, m := range mm {
|
||||
log.Debug("verifying primary store table", zap.String("table", m.Ident))
|
||||
|
||||
if tbl, err = dd.ConvertModel(m); err != nil {
|
||||
return fmt.Errorf("can not convert model %q to table: %w", m.Ident, err)
|
||||
}
|
||||
|
||||
_, err = dd.TableLookup(ctx, m.Ident)
|
||||
if err != nil {
|
||||
if !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("can not do a table lookup: %w", err)
|
||||
}
|
||||
|
||||
if err = dd.TableCreate(ctx, tbl); err != nil {
|
||||
return fmt.Errorf("can not create table from model %q: %w", m.Ident, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, idx := range tbl.Indexes {
|
||||
if idx.Ident == ddl.PRIMARY_KEY {
|
||||
// @todo move this decision to drivers!
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = dd.IndexLookup(ctx, idx.Ident, idx.TableIdent)
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return
|
||||
} else if errors.IsNotFound(err) {
|
||||
if err = dd.IndexCreate(ctx, tbl.Ident, idx); err != nil {
|
||||
return fmt.Errorf("can not create index %q on table %q: %w", idx.Ident, tbl.Ident, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropTable(ctx context.Context, s *Store, table string) error {
|
||||
_, err := s.DataDefiner.TableLookup(ctx, table)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return s.DataDefiner.TableDrop(ctx, table)
|
||||
}
|
||||
|
||||
// addColumn adds column on a table but only if table exists!
|
||||
//
|
||||
// If table does not exist adding column can be skipped
|
||||
// We can assume that 2nd step of the upgrade process will include the column
|
||||
func addColumn(ctx context.Context, s *Store, table string, attr *dal.Attribute) error {
|
||||
err, _ := addColumnExists(ctx, s, table, attr)
|
||||
return err
|
||||
}
|
||||
|
||||
func addColumnExists(ctx context.Context, s *Store, table string, attr *dal.Attribute) (err error, exists bool) {
|
||||
tbl, err := s.DataDefiner.TableLookup(ctx, table)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return err, false
|
||||
}
|
||||
|
||||
if tbl.ColumnByIdent(attr.StoreIdent()) != nil {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
s.log(ctx).Info(fmt.Sprintf("extending %q table with %q column", table, attr.StoreIdent()))
|
||||
|
||||
col, err := s.DataDefiner.ConvertAttribute(attr)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
return s.DataDefiner.ColumnAdd(ctx, table, col), false
|
||||
}
|
||||
|
||||
// dropColumns removes columns from a table but only if table exists!
|
||||
//
|
||||
// If table does not exist column removing can be skipped
|
||||
// We can assume that 2nd step of the upgrade process will omit the column
|
||||
func dropColumns(ctx context.Context, s *Store, table string, cc ...string) error {
|
||||
tbl, err := s.DataDefiner.TableLookup(ctx, table)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range cc {
|
||||
if tbl.ColumnByIdent(c) == nil {
|
||||
// column does not exist, nothing to do
|
||||
continue
|
||||
}
|
||||
|
||||
s.log(ctx).Info(fmt.Sprintf("dropping %q column from %q", c, table))
|
||||
if err := s.DataDefiner.ColumnDrop(ctx, table, c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renameColumn renames columns from a table but only if table exists!
|
||||
//
|
||||
// If table does not exist column renaming can be skipped
|
||||
// We can assume that 2nd step of the upgrade process will have columns properly nameed
|
||||
func renameColumn(ctx context.Context, s *Store, table string, from, to string) error {
|
||||
tbl, err := s.DataDefiner.TableLookup(ctx, table)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if tbl.ColumnByIdent(from) == nil {
|
||||
// from column does not exist, nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
if tbl.ColumnByIdent(to) != nil {
|
||||
// to column already exists, nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log(ctx).Info(fmt.Sprintf("renaming %q column on table %q to %q", from, table, to))
|
||||
if err := s.DataDefiner.ColumnRename(ctx, table, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableNames returns table names that Corteza creates
|
||||
func tableNames() (tnames []string) {
|
||||
cortezaModels := append(systemModels.Models(), composeModels.Models()...)
|
||||
cortezaModels = append(cortezaModels, automationModels.Models()...)
|
||||
cortezaModels = append(cortezaModels, federationModels.Models()...)
|
||||
|
||||
for _, m := range cortezaModels {
|
||||
tnames = append(tnames, m.Ident)
|
||||
}
|
||||
|
||||
return tnames
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user