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

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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
}

View 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
}

View 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
}

View 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:])
})
}
}

View 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
}

View 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)))
}

View 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)))
}

View 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
}

View 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
}

View 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...))
}

View 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"))
}

View 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)
})
}
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}
}
}

View 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...)
}

View 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
}

View 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
}
}

View 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)
})
}
}

View 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{}
)

View 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
}

View 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
}

View 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
}

View 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())
})
}
}

View 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")
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}
}

View 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)
})
}
}

View 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
}

View 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
}

View 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)
})
}
}

View 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)
}

View 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
}

View 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
}

View 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")
}

View 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
}

View 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,
// })
//}

View 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)
}

View 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)
})
}
}

View 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
}

View 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
}

View 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)
})
}
}

View 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()
)

View 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)
// }
//}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
})
}
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View 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)
}

View 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)
})
}
}

View 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)
}

View 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)
})
}
}

View 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
}

View 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
}

View 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
}

View 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,
})
}

View 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)
}

View File

@@ -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)
}

View 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
}

View 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)
})
}
}

View 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)
}

View 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)
})
}
}

View 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
}

View 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
}

View 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

View File

@@ -0,0 +1 @@
.env

View 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))
}

View 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)
}

View 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)))
})
}

View File

@@ -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)
}

View 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
}

View 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
}

View 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)
})
}
}

View 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
}

View 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

File diff suppressed because it is too large Load Diff

View 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.

View 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
}

View 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
}

View 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
}

View 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)
})
}
}

View 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
)

View 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...)
}

View 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
}

View File

@@ -0,0 +1,2 @@
package rdbms

View 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))
}
}

View 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