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

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