snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
79
server/store/adapters/rdbms/drivers/cast.go
Normal file
79
server/store/adapters/rdbms/drivers/cast.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
CheckID = exp.NewLiteralExpression(`'^[0-9]+$'`)
|
||||
CheckNumber = exp.NewLiteralExpression(`'^[+-]?[0-9]+(\.[0-9]+)?$'`)
|
||||
CheckFullISO8061 = exp.NewLiteralExpression(`'^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?)$'`)
|
||||
CheckDateISO8061 = exp.NewLiteralExpression(`'^([0-9]{4})-([0-9]{2})-([0-9]{2})(T([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?))?$'`)
|
||||
CheckTimeISO8061 = exp.NewLiteralExpression(`'^(([0-9]{4})-([0-9]{2})-([0-9]{2}))?T?([0-9]{2}):([0-9]{2}):([0-9]{2}(\.[0-9]*)?)((-([0-9]{2}):([0-9]{2})|Z)?)$'`)
|
||||
LiteralNULL = exp.NewLiteralExpression(`NULL`)
|
||||
LiteralFALSE = exp.NewLiteralExpression(`FALSE`)
|
||||
LiteralTRUE = exp.NewLiteralExpression(`TRUE`)
|
||||
)
|
||||
|
||||
func RegexpLike(format, val exp.Expression) exp.BooleanExpression {
|
||||
return exp.NewBooleanExpression(exp.RegexpLikeOp, val, format)
|
||||
}
|
||||
|
||||
func BooleanCheck(val exp.Expression) exp.Expression {
|
||||
return exp.NewCaseExpression().
|
||||
When(exp.NewBooleanExpression(exp.InOp, val, []any{LiteralTRUE, exp.NewLiteralExpression(`'true'`)}), LiteralTRUE).
|
||||
When(exp.NewBooleanExpression(exp.InOp, val, []any{LiteralFALSE, exp.NewLiteralExpression(`'false'`)}), LiteralFALSE).
|
||||
Else(LiteralNULL)
|
||||
}
|
||||
|
||||
func AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.CastExpression
|
||||
)
|
||||
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckID, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "BIGINT")
|
||||
|
||||
case *dal.TypeNumber:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckNumber, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "NUMERIC")
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckFullISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIMESTAMPTZ")
|
||||
|
||||
case *dal.TypeDate:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckDateISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DATE")
|
||||
|
||||
case *dal.TypeTime:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(RegexpLike(CheckTimeISO8061, val), val).
|
||||
Else(LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIMETZ")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
c = exp.NewCastExpression(BooleanCheck(val), "BOOLEAN")
|
||||
|
||||
default:
|
||||
return val, nil
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
}
|
||||
306
server/store/adapters/rdbms/drivers/columns.go
Normal file
306
server/store/adapters/rdbms/drivers/columns.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type (
|
||||
Column interface {
|
||||
Name() string
|
||||
Attribute() *dal.Attribute
|
||||
IsPrimaryKey() bool
|
||||
Encode(dal.ValueGetter) (any, error)
|
||||
Decode(any, dal.ValueSetter) error
|
||||
Type() Type
|
||||
}
|
||||
|
||||
SingleValueColumn struct {
|
||||
typ Type
|
||||
name string
|
||||
attr *dal.Attribute
|
||||
}
|
||||
|
||||
SimpleJsonDocColumn struct {
|
||||
name string
|
||||
attributes []*dal.Attribute
|
||||
}
|
||||
)
|
||||
|
||||
func NewSingleValueColumn(d Dialect, a *dal.Attribute) *SingleValueColumn {
|
||||
return &SingleValueColumn{
|
||||
typ: d.TypeWrap(a.Type),
|
||||
attr: a,
|
||||
name: a.StoreIdent(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) IsPrimaryKey() bool {
|
||||
return c.attr.PrimaryKey
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Attribute() *dal.Attribute {
|
||||
return c.attr
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Type() Type {
|
||||
return c.typ
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Encode(r dal.ValueGetter) (any, error) {
|
||||
val, err := r.GetValue(c.attr.Ident, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.typ.Encode(val)
|
||||
}
|
||||
|
||||
func (c *SingleValueColumn) Decode(raw any, r dal.ValueSetter) error {
|
||||
value, valid, err := c.typ.Decode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch c.attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(value) {
|
||||
value = "1"
|
||||
} else {
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
|
||||
ident := c.attr.Ident
|
||||
if !valid {
|
||||
return r.SetValue(ident, 0, nil)
|
||||
}
|
||||
|
||||
return r.SetValue(ident, 0, value)
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) IsPrimaryKey() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Attribute() *dal.Attribute {
|
||||
return c.attributes[0]
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Type() Type {
|
||||
return &TypeJSON{}
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Encode(r dal.ValueGetter) (_ any, err error) {
|
||||
var (
|
||||
aux = make(map[string][]any)
|
||||
value any
|
||||
place uint
|
||||
|
||||
// determinate how many values can we expected to store in the attribute
|
||||
//
|
||||
// implementations can return nil for the value to signal
|
||||
// that each attribute holds exactly one value
|
||||
count = r.CountValues()
|
||||
|
||||
// preset this to one just in case CountValues()
|
||||
// returns nil!
|
||||
size uint = 1
|
||||
)
|
||||
|
||||
for _, attr := range c.attributes {
|
||||
if count != nil {
|
||||
size = count[attr.Ident]
|
||||
}
|
||||
|
||||
aux[attr.Ident] = make([]any, size)
|
||||
|
||||
for place = 0; place < size; place++ {
|
||||
value, err = r.GetValue(attr.Ident, place)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// we want booleans stored as booleans
|
||||
aux[attr.Ident][place] = cast.ToBool(value)
|
||||
|
||||
default:
|
||||
// every other value is
|
||||
aux[attr.Ident][place] = value
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) Decode(raw any, r dal.ValueSetter) (err error) {
|
||||
rawJson, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return fmt.Errorf("incompatible input value type (%T), expecting *sql.RawBytes", raw)
|
||||
}
|
||||
|
||||
if len(*rawJson) == 0 {
|
||||
// gracefully handle empty strings as valid input
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
root, auxvv *fastjson.Value
|
||||
vv []*fastjson.Value
|
||||
v string
|
||||
obj *fastjson.Object
|
||||
)
|
||||
|
||||
if root, err = fastjson.ParseBytes(*rawJson); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if root.Type() == fastjson.TypeNull {
|
||||
// ignore NULL
|
||||
return
|
||||
}
|
||||
|
||||
if root.Type() != fastjson.TypeObject {
|
||||
return errors.InvalidData("expecting valid JSON object for record-values")
|
||||
}
|
||||
|
||||
if obj, err = root.Object(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, attr := range c.attributes {
|
||||
auxvv = obj.Get(attr.Ident)
|
||||
if auxvv == nil {
|
||||
// no values for the attribute
|
||||
continue
|
||||
}
|
||||
|
||||
if auxvv.Type() == fastjson.TypeNull {
|
||||
// ignore NULL
|
||||
return
|
||||
}
|
||||
|
||||
if auxvv.Type() != fastjson.TypeArray {
|
||||
return errors.InvalidData("expecting valid JSON array for record-values at key %s, got %s", attr.Ident, root.Type())
|
||||
}
|
||||
|
||||
if vv, err = auxvv.Array(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for pos, val := range vv {
|
||||
switch val.Type() {
|
||||
case fastjson.TypeString:
|
||||
v = string(val.GetStringBytes())
|
||||
default:
|
||||
v = val.String()
|
||||
}
|
||||
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(v) {
|
||||
v = "1"
|
||||
} else {
|
||||
v = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.SetValue(attr.Ident, uint(pos), v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *SimpleJsonDocColumn) DecodeOld(raw any, r dal.ValueSetter) (err error) {
|
||||
rawJson, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return fmt.Errorf("incompatible input value type (%T), expecting *sql.RawBytes", raw)
|
||||
}
|
||||
|
||||
buf := make(map[string][]any)
|
||||
if err = json.Unmarshal(*rawJson, &buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo this is too naive
|
||||
for name, vv := range buf {
|
||||
var attr *dal.Attribute
|
||||
for a := range c.attributes {
|
||||
if c.attributes[a].Ident != name {
|
||||
continue
|
||||
}
|
||||
|
||||
attr = c.attributes[a]
|
||||
}
|
||||
|
||||
if attr == nil {
|
||||
// unrecognized value in the json doc
|
||||
continue
|
||||
}
|
||||
|
||||
for pos, v := range vv {
|
||||
// now, encode the value according to JSON format constraints
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeBoolean:
|
||||
// for backward compatibility reasons
|
||||
// we need to cast true bool values to "1"
|
||||
// and use "" for other (false) values
|
||||
if cast.ToBool(v) {
|
||||
v = "1"
|
||||
} else {
|
||||
v = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.SetValue(name, uint(pos), v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !attr.MultiValue {
|
||||
// model attribute supports storing of single values only.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
attr = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
66
server/store/adapters/rdbms/drivers/columns_test.go
Normal file
66
server/store/adapters/rdbms/drivers/columns_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_SimpleJsonDocColumn_Decode(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
name string
|
||||
json string
|
||||
rvs types.RecordValueSet
|
||||
attr []*dal.Attribute
|
||||
}{
|
||||
{
|
||||
name: "empty object",
|
||||
json: `{}`,
|
||||
rvs: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
json: ``,
|
||||
rvs: nil,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
json: `{"foo":["bar"],"num":[11,"22"],"bool":[true, false]}`,
|
||||
rvs: types.RecordValueSet{
|
||||
{RecordID: 0, Name: "foo", Value: "bar"},
|
||||
{RecordID: 0, Name: "num", Value: "11"},
|
||||
{RecordID: 0, Name: "num", Value: "22", Place: 1},
|
||||
{RecordID: 0, Name: "bool", Value: "1"},
|
||||
{RecordID: 0, Name: "bool", Value: "", Place: 1},
|
||||
},
|
||||
|
||||
attr: []*dal.Attribute{
|
||||
{Ident: "foo"},
|
||||
{Ident: "num", MultiValue: true},
|
||||
{Ident: "bool", MultiValue: true, Type: &dal.TypeBoolean{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
var (
|
||||
sjdc = &SimpleJsonDocColumn{attributes: c.attr}
|
||||
raw = sql.RawBytes(c.json)
|
||||
rec = &types.Record{}
|
||||
)
|
||||
|
||||
req.NoError(sjdc.Decode(&raw, rec))
|
||||
req.Equal(c.rvs.String(), rec.Values.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
216
server/store/adapters/rdbms/drivers/dialect.go
Normal file
216
server/store/adapters/rdbms/drivers/dialect.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/ql"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
Nuances struct {
|
||||
// HavingClauseMustUseAlias
|
||||
// For example, Postgres and SQLite require
|
||||
// use of aliases inside HAVING clause and
|
||||
// MySQL does not.
|
||||
HavingClauseMustUseAlias bool
|
||||
|
||||
// TwoStepUpsert allows support for databases which don't have an upsert
|
||||
// or we simply couldn't figure out how to make work.
|
||||
//
|
||||
// TwoStepUpsert uses the context from the update statement to figure out
|
||||
// if it needs to do an insert.
|
||||
TwoStepUpsert bool
|
||||
|
||||
// @todo change this around; temporary fix as not sure how I'd rewrite
|
||||
ExpandedJsonColumnSelector func(ident string) exp.Expression
|
||||
}
|
||||
|
||||
Dialect interface {
|
||||
// Nuances returns dialect nuances
|
||||
// subtle differences between RDBMS implementations that
|
||||
// should be handled on common code
|
||||
Nuances() Nuances
|
||||
|
||||
// GOQU returns goqu's dialect wrapper struct
|
||||
GOQU() goqu.DialectWrapper
|
||||
|
||||
DialectOptions() *sqlgen.SQLDialectOptions
|
||||
|
||||
JsonQuote(exp.Expression) exp.Expression
|
||||
|
||||
// JsonExtract returns expression that returns a value from inside JSON document
|
||||
//
|
||||
// Use this when you want use JSON encoded value
|
||||
JsonExtract(exp.Expression, ...any) (exp.Expression, error)
|
||||
|
||||
// JsonExtractUnquote returns expression that returns a value from inside JSON document:
|
||||
//
|
||||
// Use this when you want to use unencoded value!
|
||||
JsonExtractUnquote(exp.Expression, ...any) (exp.Expression, error)
|
||||
|
||||
// JsonArrayContains generates expression JSON array containment check expression
|
||||
//
|
||||
// Literal values need to be JSON docs!
|
||||
//
|
||||
// @todo recheck if we really need JsonArrayContains on Dialect interface
|
||||
JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error)
|
||||
|
||||
// AttributeCast prepares complex SQL expression that verifies
|
||||
// arbitrary string value in the db and casts it to b used in
|
||||
// comparison or soring expression
|
||||
AttributeCast(*dal.Attribute, exp.Expression) (exp.Expression, error)
|
||||
|
||||
AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error)
|
||||
|
||||
// TableCodec returns table codec (encodes & decodes data to/from db table)
|
||||
TableCodec(*dal.Model) TableCodec
|
||||
|
||||
// TypeWrap returns driver's type implementation for a particular attribute type
|
||||
TypeWrap(dal.Type) Type
|
||||
|
||||
QuoteIdent(string) string
|
||||
|
||||
AggregateBase(t TableCodec, gb []dal.AggregateAttr, out []dal.AggregateAttr) *goqu.SelectDataset
|
||||
|
||||
// AttributeToColumn converts attribute to column defunition
|
||||
AttributeToColumn(*dal.Attribute) (*ddl.Column, error)
|
||||
ColumnFits(base, assert *ddl.Column) bool
|
||||
|
||||
// ExprHandler returns driver specific expression handling
|
||||
ExprHandler(*ql.ASTNode, ...exp.Expression) (exp.Expression, error)
|
||||
|
||||
// ValHandler returns driver specific value expression handling
|
||||
ValHandler(*ql.ASTNode) (exp.Expression, error)
|
||||
|
||||
// OrderedExpression returns compatible expression for ordering
|
||||
//
|
||||
// Database understand order modifiers differently. For example, MySQL does not know
|
||||
// about NULLS FIRST/LAST. Drivers should gracefully handle this.
|
||||
OrderedExpression(exp.Expression, exp.SortDirection, exp.NullSortType) exp.OrderedExpression
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goqu.SetDefaultPrepared(true)
|
||||
}
|
||||
|
||||
// @note copied to data_definer_test to avoid import cycle; if modified, fixup both parts
|
||||
func IndexFieldModifiers(attr *dal.Attribute, quoteIdent func(i string) string, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
var (
|
||||
modifier string
|
||||
out = quoteIdent(attr.StoreIdent())
|
||||
)
|
||||
|
||||
for _, m := range mm {
|
||||
switch m {
|
||||
case dal.IndexFieldModifierLower:
|
||||
modifier = "LOWER"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown index field modifier: %s", m)
|
||||
}
|
||||
|
||||
out = fmt.Sprintf("%s(%s)", modifier, out)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func OpHandlerIn(d Dialect, n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
return opHandlerIn(d, n, false, args...)
|
||||
}
|
||||
|
||||
func OpHandlerNotIn(d Dialect, n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
return opHandlerIn(d, n, true, args...)
|
||||
}
|
||||
|
||||
func opHandlerIn(d Dialect, n *ql.ASTNode, negate bool, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
if len(n.Args) == 2 && n.Args[1] != nil && n.Args[1].Meta["dal.Attribute"] != nil && n.Args[1].Meta["dal.Attribute"].(*dal.Attribute).MultiValue {
|
||||
// if right-side argument is multi-value attribute,
|
||||
// then we need to adjust the arguments a bit:
|
||||
// 1) left side, if it is a value, is encoded as JSON
|
||||
// 2) if ref we access JSON encoded value
|
||||
//
|
||||
// right side, access JSON encoded array of values.
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
for a := range n.Args {
|
||||
left := a == 0
|
||||
|
||||
switch {
|
||||
case n.Args[a].Meta != nil && n.Args[a].Meta["dal.Attribute"] != nil:
|
||||
// symbol, ident probably...
|
||||
var (
|
||||
attr = n.Args[a].Meta["dal.Attribute"].(*dal.Attribute)
|
||||
model = n.Args[a].Meta["dal.Model"].(*dal.Model)
|
||||
storeIdent = exp.NewIdentifierExpression(
|
||||
"",
|
||||
model.Ident,
|
||||
attr.StoreIdent(),
|
||||
)
|
||||
|
||||
_, isJSON = attr.Store.(*dal.CodecRecordValueSetJSON)
|
||||
)
|
||||
|
||||
if attr.MultiValue {
|
||||
if left {
|
||||
return nil, fmt.Errorf("multi-value attribute %s cannot be used as left-side argument of IN operator", attr.Ident)
|
||||
}
|
||||
|
||||
args[a], err = d.JsonExtract(storeIdent, attr.Ident)
|
||||
} else {
|
||||
if !left {
|
||||
return nil, fmt.Errorf("single-value attribute %s cannot be used as right-side argument of IN operator", attr.Ident)
|
||||
}
|
||||
|
||||
if isJSON {
|
||||
args[a], err = d.JsonExtract(storeIdent, attr.Ident, 0)
|
||||
} else if attr.Type.Type() == dal.AttributeTypeBoolean {
|
||||
// SQLite converts boolean to integer but JSON stores boolean as boolean
|
||||
args[a] = exp.NewCaseExpression().
|
||||
When(exp.NewBooleanExpression(exp.EqOp, args[a], LiteralTRUE), exp.NewLiteralExpression(`'true'`)).
|
||||
When(exp.NewBooleanExpression(exp.EqOp, args[a], LiteralFALSE), exp.NewLiteralExpression(`'false'`)).
|
||||
Else(LiteralNULL)
|
||||
} else {
|
||||
args[a] = d.JsonQuote(args[a])
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case a == 0 && n.Args[a].Value != nil:
|
||||
// for 1st arg only, when value
|
||||
var jsonDoc []byte
|
||||
jsonDoc, err = json.Marshal(n.Args[a].Value.V.Get())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// encode it as json
|
||||
args[a] = exp.NewLiteralExpression("?", string(jsonDoc))
|
||||
}
|
||||
}
|
||||
|
||||
expr, err = d.JsonArrayContains(args[0], args[1])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if negate {
|
||||
expr = exp.NewLiteralExpression("NOT ?", expr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported IN operator on a single value field")
|
||||
}
|
||||
46
server/store/adapters/rdbms/drivers/id.go
Normal file
46
server/store/adapters/rdbms/drivers/id.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// nullUint64 represents an uint64 that may be null.
|
||||
// nullUint64 implements the Scanner interface so
|
||||
// it can be used as a scan dåestination, similar to NullString.
|
||||
type ID struct {
|
||||
ID uint64
|
||||
Valid bool // Valid is true if Uint64 is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (n *ID) Scan(value any) (err error) {
|
||||
if value == nil {
|
||||
n.ID, n.Valid = 0, false
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOT NULL => Valid!
|
||||
n.Valid = true
|
||||
if b, is := value.([]byte); is {
|
||||
n.ID, err = strconv.ParseUint(string(b), 10, 64)
|
||||
} else {
|
||||
n.ID, err = cast.ToUint64E(value)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (n ID) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return n.ID, nil
|
||||
}
|
||||
|
||||
func (n ID) String() string {
|
||||
return strconv.FormatUint(n.ID, 10)
|
||||
}
|
||||
125
server/store/adapters/rdbms/drivers/mssql/connect.go
Normal file
125
server/store/adapters/rdbms/drivers/mssql/connect.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
mssql "github.com/denisenkom/go-mssqldb"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "sqlserver"
|
||||
|
||||
// debug schema with verbose logging
|
||||
// @todo this won't connect yet
|
||||
// debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// store.Register(Connect, SCHEMA, debugSchema)
|
||||
// sql.Register(debugSchema, sqlmw.Driver(new(mssql.Driver), instrumentation.Debug()))
|
||||
store.Register(Connect, SCHEMA)
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db, err := connectBase(ctx, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
// TxRetryErrHandler: txRetryErrHandler,
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func connectBase(ctx context.Context, cfg *rdbms.ConnConfig) (db *sqlx.DB, err error) {
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// See https://stackoverflow.com/questions/2901453/sql-standard-to-escape-column-names
|
||||
if _, err = db.ExecContext(ctx, `SET QUOTED_IDENTIFIER ON`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
//
|
||||
// If param is missing it sets it to default but returns
|
||||
// error in case of incorrect param value
|
||||
//
|
||||
// See https://github.com/go-sql-driver/mysql for available dsn params
|
||||
//
|
||||
func NewConfig(dsn string) (c *rdbms.ConnConfig, err error) {
|
||||
const (
|
||||
validScheme = "sqlserver"
|
||||
)
|
||||
var (
|
||||
scheme string
|
||||
u *url.URL
|
||||
)
|
||||
c = &rdbms.ConnConfig{DriverName: scheme}
|
||||
|
||||
if u, err = url.Parse(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "sqlserver") {
|
||||
scheme = u.Scheme
|
||||
u.Scheme = validScheme
|
||||
}
|
||||
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: scheme,
|
||||
DataSourceName: u.String(),
|
||||
DBName: strings.Trim(u.Path, "/"),
|
||||
MaskedDSN: u.Redacted(),
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(mssql.Error); ok {
|
||||
// https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/database-engine-events-and-errors?view=sql-server-ver16
|
||||
switch implErr.Number {
|
||||
case 987, 2627: // Can't write, because of unique constraint, to table '%s'
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
32
server/store/adapters/rdbms/drivers/mssql/dal.go
Normal file
32
server/store/adapters/rdbms/drivers/mssql/dal.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
dal.RegisterConnector(dalConnector, SCHEMA)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
db, err := connectBase(ctx, cfg)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
198
server/store/adapters/rdbms/drivers/mssql/data_definer.go
Normal file
198
server/store/adapters/rdbms/drivers/mssql/data_definer.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *mssqlDialect
|
||||
}
|
||||
|
||||
// Custom ddl commands tweaked to SQL server specifics.
|
||||
// It might be cleaner to solve this with goqu or some custom string templates
|
||||
// but this will do for now.
|
||||
addColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Column *ddl.Column
|
||||
}
|
||||
|
||||
renameColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
reTypeColumn struct {
|
||||
Dialect *mssqlDialect
|
||||
Table string
|
||||
Column string
|
||||
Type *ddl.ColumnType
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (tbl *ddl.Table, err error) {
|
||||
tbl, err = ddl.ConvertModel(m, dd.d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We'll solve conditional indexes on the app level
|
||||
// @todo check if we can use what they provide; the code to replace upsert
|
||||
// wasn't working for me.
|
||||
//
|
||||
// We need to prevent these indexes from adding
|
||||
//
|
||||
// loop through indexes and remove all with predicate
|
||||
|
||||
for i := len(tbl.Indexes) - 1; i >= 0; i-- {
|
||||
if tbl.Indexes[i].Predicate != "" {
|
||||
tbl.Indexes = append(tbl.Indexes[:i], tbl.Indexes[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &addColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &renameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &reTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *addColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
sql = fmt.Sprintf(
|
||||
`ALTER TABLE %s ADD %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column.Ident),
|
||||
c.Column.Type.Name,
|
||||
)
|
||||
|
||||
if !c.Column.Type.Null {
|
||||
sql += " NOT NULL"
|
||||
}
|
||||
|
||||
if len(c.Column.Default) > 0 {
|
||||
// @todo right now we can (and need to) trust that default
|
||||
// values are unharmful!
|
||||
sql += " DEFAULT " + c.Column.Default
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *renameColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`EXEC sp_RENAME '%s.%s' , '%s', 'COLUMN'`,
|
||||
c.Table,
|
||||
c.Old,
|
||||
c.New,
|
||||
), nil, nil
|
||||
}
|
||||
|
||||
func (c *reTypeColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s ALTER COLUMN %s %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
c.Type.Name,
|
||||
), nil, nil
|
||||
}
|
||||
399
server/store/adapters/rdbms/drivers/mssql/dialect.go
Normal file
399
server/store/adapters/rdbms/drivers/mssql/dialect.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/cast2"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/sqlserver"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
mssqlDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &mssqlDialect{}
|
||||
|
||||
dialect = &mssqlDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("sqlserver")
|
||||
goquDialectOptions *sqlgen.SQLDialectOptions
|
||||
quoteIdent = string(sqlserver.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
TwoStepUpsert: true,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goquDialectOptions = sqlserver.DialectOptions()
|
||||
|
||||
// MSSQL doesn't support the classic boolean constants but we still need to
|
||||
// boolean expressions.
|
||||
// Disallowing boolean datatype on goqu level is too strict and it prevents most
|
||||
// of the queries to work.
|
||||
goquDialectOptions.BooleanDataTypeSupported = true
|
||||
|
||||
// Use 1/0 as an alternative to booleans
|
||||
goquDialectOptions.True = []byte("1")
|
||||
goquDialectOptions.False = []byte("0")
|
||||
goquDialectOptions.TruncateClause = []byte("TRUNCATE TABLE")
|
||||
|
||||
// d.CastFragment = []byte("TRY_CONVERT")
|
||||
|
||||
// Overriding vanila dialect
|
||||
goqu.RegisterDialect("sqlserver", goquDialectOptions)
|
||||
}
|
||||
|
||||
func Dialect() *mssqlDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (mssqlDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d mssqlDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
// @todo as is, aggregation is not offloaded to mssql
|
||||
panic("not implemented")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mssqlDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (mssqlDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (mssqlDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d mssqlDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"JSON_VALUE",
|
||||
expr,
|
||||
exp.NewLiteralExpression("'$[0]'"),
|
||||
)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonExtract(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewLiteralExpression("CASE WHEN ISJSON(?) = 1 THEN ? ELSE NULL END", jsonDoc, exp.NewSQLFunctionExpression("JSON_QUERY", jsonDoc, path)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d mssqlDialect) JsonExtractUnquote(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewLiteralExpression("CASE WHEN ISJSON(?) = 1 THEN ? ELSE NULL END", jsonDoc, exp.NewSQLFunctionExpression("JSON_VALUE", jsonDoc, path)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares sqlserver compatible comparison of value (or ident) and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, JSON_EXTRACT(needle, '$.f3'), '$.f2')
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, '"needle"', '$.f2')
|
||||
//
|
||||
// This approach is not optimal, but it is the only way to make it work
|
||||
func (d mssqlDialect) JsonArrayContains(needle, haystack exp.Expression) (_ exp.Expression, err error) {
|
||||
// @todo untested
|
||||
return exp.NewLiteralExpression(
|
||||
"? IN ?",
|
||||
needle,
|
||||
exp.NewSQLFunctionExpression("OPENJSON", haystack),
|
||||
), nil
|
||||
// return exp.NewSQLFunctionExpression(
|
||||
|
||||
// "JSON_CONTAINS",
|
||||
// haystack, needle), nil
|
||||
|
||||
// return exp.NewSQLFunctionExpression("JSON_CONTAINS", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d mssqlDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
return &drivers.TypeTimestamp{&dal.TypeTimestamp{
|
||||
Nullable: c.Nullable,
|
||||
|
||||
// sqlserver does not support timezone
|
||||
Timezone: false,
|
||||
|
||||
// sqlserver does not support precision
|
||||
Precision: 0,
|
||||
}}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
// AttributeCast for sqlserver
|
||||
//
|
||||
// https://dev.sqlserver.com/doc/refman/8.0/en/cast-functions.html#function_cast
|
||||
func (mssqlDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (expr exp.Expression, err error) {
|
||||
|
||||
switch attr.Type.(type) {
|
||||
|
||||
case *dal.TypeText:
|
||||
expr = exp.NewCastExpression(val, "VARCHAR(MAX)")
|
||||
|
||||
default:
|
||||
return attributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (mssqlDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (mssqlDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "BIGINT"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "DATETIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "DECIMAL"
|
||||
// @todo precision, scale?
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "VARCHAR(MAX)"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "VARBINARY(MAX)"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BIT"
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (mssqlDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"datetime": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"time": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"date": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"decimal": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
"text": {
|
||||
"nvarchar": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"varchar": {
|
||||
"nvarchar": true,
|
||||
},
|
||||
"nvarchar": {
|
||||
"varchar": true,
|
||||
},
|
||||
"varbinary": {},
|
||||
"bit": {},
|
||||
"char": {
|
||||
"varchar": true,
|
||||
"nvarchar": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// @note mssql represents max as -1 so we're just going with this
|
||||
if assertMeta[0] == "max" {
|
||||
assertMeta[0] = "-1"
|
||||
}
|
||||
if targetMeta[0] == "max" {
|
||||
targetMeta[0] = "-1"
|
||||
}
|
||||
|
||||
// Check varchar size
|
||||
for i := len(assertMeta); i < 1; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
assertA := cast.ToInt(assertMeta[0])
|
||||
|
||||
for i := len(targetMeta); i < 1; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
targetA := cast.ToInt(targetMeta[0])
|
||||
|
||||
// -1 means no limit so it can fit any length
|
||||
// - if target is max, any varchar fits
|
||||
if targetA == -1 {
|
||||
return baseMatch
|
||||
}
|
||||
// - if assert is max, only max fits
|
||||
if assertA == -1 {
|
||||
return baseMatch && targetA == -1
|
||||
}
|
||||
|
||||
return baseMatch && assertA <= targetA
|
||||
|
||||
case assertName == "decimal" && targetName == "decimal":
|
||||
// Check decimal size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
// @todo untested
|
||||
func (d mssqlDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return exp.NewLiteralExpression("?"+strings.Repeat(" || ?", len(args)-1), cast2.Anys(args...)...), nil
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
return ql.DefaultValueHandler(n)
|
||||
}
|
||||
|
||||
func (d mssqlDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, _ exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
|
||||
// attributeCast uses mssql's TRY_CONVERT to avoid the need for regex value validation
|
||||
// since mssql doesn't support regexes, nor can we use the extended LIKE.
|
||||
func attributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(BIGINT,?)", val), nil
|
||||
|
||||
case *dal.TypeNumber:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(NUMERIC,?)", val), nil
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(DATETIMEOFFSET,?)", val), nil
|
||||
|
||||
case *dal.TypeDate:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(DATE,?)", val), nil
|
||||
|
||||
case *dal.TypeTime:
|
||||
return exp.NewLiteralExpression("TRY_CONVERT(TIME,?)", val), nil
|
||||
|
||||
case *dal.
|
||||
TypeBoolean:
|
||||
return exp.NewLiteralExpression("CASE ? WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE TRY_CONVERT(BIT,?) END", val, val), nil
|
||||
|
||||
default:
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
191
server/store/adapters/rdbms/drivers/mssql/dialect_test.go
Normal file
191
server/store/adapters/rdbms/drivers/mssql/dialect_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (varchar)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(max)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(max)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "decimal fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "decimal doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "decimal(2,3)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(84)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar(max) fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(max)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar(max) doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(max)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetime exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetime doesn't fit time",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time doesn't fit datetime",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetime"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "datetimeoffset exact match",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetimeoffset"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "datetimeoffset"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
d := mssqlDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
268
server/store/adapters/rdbms/drivers/mssql/information_schema.go
Normal file
268
server/store/adapters/rdbms/drivers/mssql/information_schema.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect(dbname).Where(
|
||||
// We don't need to check for DB since the values are already scoped there
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect(dbname string) *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"IS_NULLABLE",
|
||||
"DATA_TYPE",
|
||||
"CHARACTER_MAXIMUM_LENGTH",
|
||||
"NUMERIC_SCALE",
|
||||
"DATETIME_PRECISION",
|
||||
).
|
||||
// @note this goqu.I is a cheat; try to figure out if we have something nicer available (same applies to lower code)
|
||||
From(goqu.I(fmt.Sprintf("%s.INFORMATION_SCHEMA.COLUMNS", dbname))).
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ORDINAL_POSITION"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"TABLE_NAME"`
|
||||
Column string `db:"COLUMN_NAME"`
|
||||
IsNullable string `db:"IS_NULLABLE"`
|
||||
Type string `db:"DATA_TYPE"`
|
||||
// We'll use these two to get numeric's precision and scale
|
||||
Precision any `db:"DATETIME_PRECISION"`
|
||||
Scale any `db:"NUMERIC_SCALE"`
|
||||
// We'll use this one to get varchar length
|
||||
MaxLength any `db:"CHARACTER_MAXIMUM_LENGTH"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
if v.Type == "decimal" {
|
||||
var (
|
||||
p, s int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.Precision) {
|
||||
p = cast.ToInt(v.Precision)
|
||||
}
|
||||
if !reflect2.IsNil(v.Scale) {
|
||||
s = cast.ToInt(v.Scale)
|
||||
}
|
||||
|
||||
if p+s > 0 {
|
||||
v.Type = fmt.Sprintf("%s(%d,%d)", v.Type, p, s)
|
||||
}
|
||||
}
|
||||
|
||||
if v.Type == "varchar" {
|
||||
var (
|
||||
l int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.MaxLength) {
|
||||
l = cast.ToInt(v.MaxLength)
|
||||
}
|
||||
|
||||
v.Type = fmt.Sprintf("%s(%d)", v.Type, l)
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: v.Type,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, dbname string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndexOnly = i.indexSelect(dbname).Where(
|
||||
// We don't need to check TABLE_SCHEMA because it's all scoped under there already
|
||||
exp.ParseIdentifier("si.name").Eq(index),
|
||||
exp.ParseIdentifier("t.name").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndexOnly); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect(dbname string) *goqu.SelectDataset {
|
||||
x := dialect.GOQU().Select(
|
||||
goqu.I("si.name").As("INDEX_NAME"),
|
||||
goqu.I("t.name").As("TABLE_NAME"),
|
||||
goqu.I("col.name").As("COLUMN_NAME"),
|
||||
goqu.I("si.type_desc").As("INDEX_TYPE"),
|
||||
|
||||
// @todo
|
||||
exp.NewAliasExpression(exp.NewLiteralExpression("''"), "EXPRESSION"),
|
||||
exp.NewAliasExpression(exp.NewLiteralExpression("''"), "INDEX_COMMENT"),
|
||||
).
|
||||
From(
|
||||
goqu.From(goqu.I(fmt.Sprintf("%s.sys.indexes", dbname))).As("si"),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.index_columns", dbname)).As("ic"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id = ic.object_id and si.index_id = ic.index_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.columns", dbname)).As("col"),
|
||||
goqu.On(exp.NewLiteralExpression("ic.object_id = col.object_id and ic.column_id = col.column_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.tables", dbname)).As("t"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id = t.object_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.objects", dbname)).As("so"),
|
||||
goqu.On(exp.NewLiteralExpression("si.object_id=so.object_id")),
|
||||
).
|
||||
Join(
|
||||
goqu.I(fmt.Sprintf("%s.sys.schemas", dbname)).As("sc"),
|
||||
goqu.On(exp.NewLiteralExpression("so.schema_id=sc.schema_id")),
|
||||
)
|
||||
|
||||
return x
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Name string `db:"INDEX_NAME"`
|
||||
Table string `db:"TABLE_NAME"`
|
||||
NonUnique bool `db:"NON_UNIQUE"`
|
||||
Type string `db:"INDEX_TYPE"`
|
||||
|
||||
// @todo there's also a "COMMENT" column?
|
||||
Comment string `db:"INDEX_COMMENT"`
|
||||
|
||||
Expression sql.NullString `db:"EXPRESSION"`
|
||||
ColumnName sql.NullString `db:"COLUMN_NAME"`
|
||||
ColumnSubPart sql.NullInt32 `db:"SUB_PART"`
|
||||
ColumnCollation sql.NullString `db:"COLLATION"`
|
||||
|
||||
// stats
|
||||
ColumnStatsCardinality int64 `db:"CARDINALITY"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, 0, 10)
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
if at, has = n2p[a.Name]; !has {
|
||||
out = append(out, &ddl.Index{
|
||||
Ident: a.Name,
|
||||
TableIdent: a.Table,
|
||||
Type: a.Type,
|
||||
Comment: a.Comment,
|
||||
Fields: make([]*ddl.IndexField, 0),
|
||||
Unique: !a.NonUnique,
|
||||
Predicate: "",
|
||||
Meta: nil,
|
||||
})
|
||||
n2p[a.Name] = p
|
||||
at = p
|
||||
}
|
||||
|
||||
col := &ddl.IndexField{
|
||||
Length: int(a.ColumnSubPart.Int32),
|
||||
Statistics: &ddl.IndexFieldStatistics{
|
||||
Cardinality: a.ColumnStatsCardinality,
|
||||
},
|
||||
}
|
||||
|
||||
switch a.ColumnCollation.String {
|
||||
case "A":
|
||||
col.Sort = dal.IndexFieldSortAsc
|
||||
case "D":
|
||||
col.Sort = dal.IndexFieldSortDesc
|
||||
}
|
||||
|
||||
if a.Expression.Valid {
|
||||
col.Expression = a.Expression.String
|
||||
} else {
|
||||
col.Column = a.ColumnName.String
|
||||
}
|
||||
|
||||
out[at].Fields = append(out[at].Fields, col)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
41
server/store/adapters/rdbms/drivers/mssql/json.go
Normal file
41
server/store/adapters/rdbms/drivers/mssql/json.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
func jsonPathExpr(pp ...any) (exp.LiteralExpression, error) {
|
||||
if path, err := jsonPath(pp...); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return exp.NewLiteralExpression("'" + path + "'"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/mssql/json_test.go
Normal file
42
server/store/adapters/rdbms/drivers/mssql/json_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
152
server/store/adapters/rdbms/drivers/mssql/ql.go
Normal file
152
server/store/adapters/rdbms/drivers/mssql/ql.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"interval": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_add": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_sub": {
|
||||
HandlerE: func(e ...exp.Expression) (exp.Expression, error) {
|
||||
return nil, fmt.Errorf("@todo not implemented")
|
||||
},
|
||||
},
|
||||
|
||||
"date_format": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"FORMAT",
|
||||
args[0],
|
||||
translateDateFormatParam(args[1]),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("GETDATE")
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(date,?)", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("DATEPART(QUARTER, ?)", args[0])
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("YEAR", args[0])
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("MONTH", args[0])
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DAY", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(DATETIME, ?)", args[0])
|
||||
},
|
||||
},
|
||||
"time": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("CONVERT(TIME, ?)", args[0])
|
||||
},
|
||||
},
|
||||
}.ExprHandlers()
|
||||
|
||||
// @todo consider
|
||||
// supportedSubstitutions = map[string]bool{
|
||||
// "d": true,
|
||||
// "H": true,
|
||||
// "j": true,
|
||||
// "m": true,
|
||||
// "M": true,
|
||||
// "S": true,
|
||||
// "w": true,
|
||||
// "W": true,
|
||||
// "Y": true,
|
||||
// "%": true,
|
||||
// }
|
||||
)
|
||||
|
||||
func translateDateFormatParam(e interface{}) interface{} {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e
|
||||
}
|
||||
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
return dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func dateFormatReplacer(s string) string {
|
||||
return strings.NewReplacer(
|
||||
// @todo Doing ...%dT%H... (for iso timestamp) pgsql doesn't format it correctly
|
||||
// so I'm covering this edge case.
|
||||
// We should fix this properly when we redo record storage.
|
||||
|
||||
// - this is day of month from 01-31
|
||||
`%d`, `dd`,
|
||||
// - this is the month number from 01-12 !! %m is from 0
|
||||
`%c`, `MM`,
|
||||
// - this is the month number from 01-12 !! %m is from 0
|
||||
`%m`, `MM`,
|
||||
|
||||
// - month name abbreviated
|
||||
`%b`, `MMM`,
|
||||
// - this is the month spelled out
|
||||
`%M`, `MMMM`,
|
||||
|
||||
// - this is the year with two digits
|
||||
`%y`, `yy`,
|
||||
// - this is the year with four digits
|
||||
`%Y`, `yyyy`,
|
||||
|
||||
// - this is the hour from 01-12
|
||||
`%l`, `hh`,
|
||||
// - this is the hour from 00-23
|
||||
`%k`, `HH`,
|
||||
|
||||
// - this is the minute from 00-59
|
||||
`%i`, `mm`,
|
||||
// - this is the second from 00-59
|
||||
`%s`, `ss`,
|
||||
// - this is the second from 00-59
|
||||
`%S`, `ss`,
|
||||
|
||||
// - this shows either AM or PM
|
||||
`%r`, `tt`,
|
||||
).Replace(s)
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/mssql/types.go
Normal file
42
server/store/adapters/rdbms/drivers/mssql/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, 0)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
199
server/store/adapters/rdbms/drivers/mysql/connect.go
Normal file
199
server/store/adapters/rdbms/drivers/mysql/connect.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "mysql"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
store.Register(Connect, SCHEMA, debugSchema)
|
||||
sql.Register(debugSchema, sqlmw.Driver(new(mysql.MySQLDriver), instrumentation.Debug()))
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db, err := connectBase(ctx, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
TxRetryErrHandler: txRetryErrHandler,
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func connectBase(ctx context.Context, cfg *rdbms.ConnConfig) (db *sqlx.DB, err error) {
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// See https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi for details
|
||||
if _, err = db.ExecContext(ctx, `SET SESSION sql_mode = 'ANSI'`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
//
|
||||
// If param is missing it sets it to default but returns
|
||||
// error in case of incorrect param value
|
||||
//
|
||||
// See https://github.com/go-sql-driver/mysql for available dsn params
|
||||
//
|
||||
func NewConfig(in string) (*rdbms.ConnConfig, error) {
|
||||
const (
|
||||
schemeDel = "://"
|
||||
validScheme = "mysql"
|
||||
)
|
||||
|
||||
var (
|
||||
endOfSchema = strings.Index(in, schemeDel)
|
||||
|
||||
c = &rdbms.ConnConfig{}
|
||||
)
|
||||
|
||||
if endOfSchema > 0 && (in[:endOfSchema] == validScheme || strings.HasPrefix(in[:endOfSchema], validScheme+"+")) {
|
||||
c.DriverName = in[:endOfSchema]
|
||||
c.DataSourceName = in[endOfSchema+len(schemeDel):]
|
||||
} else {
|
||||
return nil, fmt.Errorf("expecting valid schema (mysql://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
if pdsn, err := mysql.ParseDSN(c.DataSourceName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
// Ensure driver parses time
|
||||
pdsn.ParseTime = true
|
||||
|
||||
if pdsn.Collation == "" {
|
||||
pdsn.Collation = "utf8mb4_general_ci"
|
||||
}
|
||||
|
||||
if pdsn.Params == nil {
|
||||
pdsn.Params = make(map[string]string)
|
||||
}
|
||||
|
||||
if pdsn.Params["charset"] == "" {
|
||||
pdsn.Params["charset"] = "utf8mb4"
|
||||
}
|
||||
|
||||
c.DataSourceName = pdsn.FormatDSN()
|
||||
c.DBName = pdsn.DBName
|
||||
c.MaskedDSN = maskDSN(pdsn)
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Connection setup
|
||||
func connSetup(ctx context.Context, db sqlx.ExecerContext) (err error) {
|
||||
// See https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi for details
|
||||
if _, err = db.ExecContext(ctx, `SET SESSION sql_mode = 'ANSI'`); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func maskDSN(myCnf *mysql.Config) string {
|
||||
var (
|
||||
maskedDSN = myCnf.Clone()
|
||||
)
|
||||
|
||||
if maskedDSN.Passwd != "" {
|
||||
// following the same logic as url.Redacted (used for postgresql)
|
||||
maskedDSN.Passwd = "xxxxx"
|
||||
}
|
||||
|
||||
return myCnf.FormatDSN()
|
||||
}
|
||||
|
||||
func txRetryErrHandler(try int, err error) bool {
|
||||
var (
|
||||
mysqlErr *mysql.MySQLError
|
||||
ok bool
|
||||
)
|
||||
|
||||
// unwrap errors until we find one comming from MySQL
|
||||
for errors.Unwrap(err) != nil {
|
||||
err = errors.Unwrap(err)
|
||||
|
||||
mysqlErr, ok = err.(*mysql.MySQLError)
|
||||
if !ok || mysqlErr == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if mysqlErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch mysqlErr.Number {
|
||||
case
|
||||
1205, // lock within transaction
|
||||
1206, // total number of locks exceeded the lock table size
|
||||
1208, // not allowed while thread is holding global read lock
|
||||
1213: // deadlock found
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(*mysql.MySQLError); ok {
|
||||
// https://www.fromdual.com/de/mysql-error-codes-and-messages
|
||||
switch implErr.Number {
|
||||
case 1062: // Can't write, because of unique constraint, to table '%s'
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
27
server/store/adapters/rdbms/drivers/mysql/connect_test.go
Normal file
27
server/store/adapters/rdbms/drivers/mysql/connect_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcDataSourceName(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
c *rdbms.ConnConfig
|
||||
err error
|
||||
)
|
||||
|
||||
c, err = NewConfig("mysql://uid:@/dbname?parseTime=true")
|
||||
req.NoError(err)
|
||||
req.Contains(c.DataSourceName, "parseTime=true")
|
||||
req.Equal(c.DBName, "dbname")
|
||||
|
||||
c, err = NewConfig("mysql+foo://uid:@/dbname")
|
||||
req.NoError(err)
|
||||
req.Contains(c.DataSourceName, "parseTime=true")
|
||||
req.Equal(c.DriverName, "mysql+foo")
|
||||
req.Equal(c.DBName, "dbname")
|
||||
}
|
||||
31
server/store/adapters/rdbms/drivers/mysql/dal.go
Normal file
31
server/store/adapters/rdbms/drivers/mysql/dal.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
cfg, err := NewConfig(dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
db, err := connectBase(ctx, cfg)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
269
server/store/adapters/rdbms/drivers/mysql/data_definer.go
Normal file
269
server/store/adapters/rdbms/drivers/mysql/data_definer.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *mysqlDialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (tbl *ddl.Table, err error) {
|
||||
tbl, err = ddl.ConvertModel(m, dd.d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Sadly, MySQL does not support conditional indexes
|
||||
// We'll solve that on an app level.
|
||||
//
|
||||
// We need to prevent these indexes from adding
|
||||
//
|
||||
// loop through indexes and remove all with predicate
|
||||
|
||||
for i := len(tbl.Indexes) - 1; i >= 0; i-- {
|
||||
if tbl.Indexes[i].Predicate != "" {
|
||||
tbl.Indexes = append(tbl.Indexes[:i], tbl.Indexes[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.ReTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
OmitIfNotExistsClause: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
//// TableExists checks if table exists in the MySQL database
|
||||
//func (s *schema) TableExists(ctx context.Context, db sqlx.QueryerContext, table string) (bool, error) {
|
||||
// return ddl.TableExists(ctx, db, Dialect(), table, s.dbName)
|
||||
//}
|
||||
//
|
||||
//// ColumnExists checks if column exists in the MySQL table
|
||||
//func (s *schema) ColumnExists(ctx context.Context, db sqlx.QueryerContext, column, table string) (bool, error) {
|
||||
// return ddl.ColumnExists(ctx, db, Dialect(), column, table, s.dbName)
|
||||
//}
|
||||
//
|
||||
//// CreateTable
|
||||
////
|
||||
//// MySQL does not hav CREATE-INDEX-IF-NOT-EXISTS; we need to check index existence manually
|
||||
//func (s *schema) CreateTable(ctx context.Context, db sqlx.ExtContext, t *ddl.Table) (err error) {
|
||||
//
|
||||
// tc := &ddl.CreateTable{
|
||||
// Dialect: Dialect(),
|
||||
// Table: t,
|
||||
// SuffixClause: "ENGINE=InnoDB DEFAULT CHARSET=utf8",
|
||||
// }
|
||||
//
|
||||
// if err = ddl.Exec(ctx, db, tc); err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// for _, index := range t.Indexes {
|
||||
// if index.Condition != "" {
|
||||
// // MySQL, sad little DB does not support
|
||||
// // conditional indexes
|
||||
// //
|
||||
// // We'll solve this on an application level
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// var doesIt bool
|
||||
// if doesIt, err = ddl.IndexExists(ctx, db, Dialect(), index.Name, index.Table, s.dbName); err != nil {
|
||||
// return
|
||||
// } else if doesIt {
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// ic := &ddl.CreateIndex{
|
||||
// Dialect: Dialect(),
|
||||
// Index: index,
|
||||
// OmitIfNotExistsClause: true,
|
||||
// }
|
||||
//
|
||||
// if err = ddl.Exec(ctx, db, ic); err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//func (s *schema) AddColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, cc ...*ddl.Column) (err error) {
|
||||
// var (
|
||||
// aux []any
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// for _, c := range cc {
|
||||
// // check column existence
|
||||
// if exists, err = s.ColumnExists(ctx, db, c.Name, t.Name); err != nil {
|
||||
// return
|
||||
// } else if exists {
|
||||
// // column exists
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Sadly, some column types in MySQL can not have default values
|
||||
// if c.Type.Type == ddl.ColumnTypejsonb|| c.Type.Type == ddl.ColumnTypeBinary || c.Type.Type == ddl.ColumnTypeText {
|
||||
// c.DefaultValue = ""
|
||||
// }
|
||||
//
|
||||
// aux = append(aux, &ddl.AddColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Column: c,
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, aux...)
|
||||
//}
|
||||
//
|
||||
//func (s *schema) DropColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, cc ...string) (err error) {
|
||||
// var (
|
||||
// aux []any
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// for _, c := range cc {
|
||||
// // check column existence
|
||||
// if exists, err = s.ColumnExists(ctx, db, c, t.Name); err != nil {
|
||||
// return
|
||||
// } else if !exists {
|
||||
// // column exists
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// aux = append(aux, &ddl.DropColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Column: c,
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, aux...)
|
||||
//}
|
||||
//
|
||||
//func (s *schema) RenameColumn(ctx context.Context, db sqlx.ExtContext, t *ddl.Table, old, new string) (err error) {
|
||||
// var (
|
||||
// exists bool
|
||||
// )
|
||||
//
|
||||
// if exists, err = s.ColumnExists(ctx, db, old, t.Name); err != nil || !exists {
|
||||
// // error or old column does not exist
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if exists, err = s.ColumnExists(ctx, db, new, t.Name); err != nil || exists {
|
||||
// // error or new column already exists
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// return ddl.Exec(ctx, db, &ddl.RenameColumn{
|
||||
// Dialect: dialect,
|
||||
// Table: t,
|
||||
// Old: old,
|
||||
// New: new,
|
||||
// })
|
||||
//}
|
||||
434
server/store/adapters/rdbms/drivers/mysql/dialect.go
Normal file
434
server/store/adapters/rdbms/drivers/mysql/dialect.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/mysql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
mysqlDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &mysqlDialect{}
|
||||
|
||||
dialect = &mysqlDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("mysql")
|
||||
goquDialectOptions = mysql.DialectOptions()
|
||||
quoteIdent = string(mysql.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: false,
|
||||
}
|
||||
)
|
||||
|
||||
func Dialect() *mysqlDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (mysqlDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d mysqlDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
ax, err := t.AttributeExpressionQuoted(colName)
|
||||
if err != nil {
|
||||
q = q.SetError(err)
|
||||
return q
|
||||
}
|
||||
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("JSON_TABLE",
|
||||
ax,
|
||||
exp.NewLiteralExpression(fmt.Sprintf(`'$[*]' COLUMNS (%s TEXT PATH '$')`, colName)),
|
||||
).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func (mysqlDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (mysqlDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (mysqlDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d mysqlDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression(
|
||||
"JSON_EXTRACT",
|
||||
exp.NewSQLFunctionExpression("JSON_ARRAY", expr),
|
||||
exp.NewLiteralExpression("'$[0]'"),
|
||||
)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonExtract(jsonDoc exp.Expression, pp ...any) (path exp.Expression, err error) {
|
||||
if path, err = jsonPathExpr(pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewSQLFunctionExpression("JSON_EXTRACT", jsonDoc, path), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d mysqlDialect) JsonExtractUnquote(jsonDoc exp.Expression, pp ...any) (_ exp.Expression, err error) {
|
||||
if jsonDoc, err = d.JsonExtract(jsonDoc, pp...); err != nil {
|
||||
return
|
||||
} else {
|
||||
return exp.NewSQLFunctionExpression("JSON_UNQUOTE", jsonDoc), nil
|
||||
}
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares MySQL compatible comparison of value (or ident) and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, JSON_EXTRACT(needle, '$.f3'), '$.f2')
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// JSON_CONTAINS(v, '"needle"', '$.f2')
|
||||
//
|
||||
// This approach is not optimal, but it is the only way to make it work
|
||||
func (d mysqlDialect) JsonArrayContains(needle, haystack exp.Expression) (_ exp.Expression, err error) {
|
||||
return exp.NewSQLFunctionExpression("JSON_CONTAINS", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d mysqlDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
return &drivers.TypeTimestamp{&dal.TypeTimestamp{
|
||||
Nullable: c.Nullable,
|
||||
|
||||
// mysql does not support timezone
|
||||
Timezone: false,
|
||||
|
||||
// mysql does not support precision
|
||||
Precision: 0,
|
||||
}}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
// AttributeCast for mySQL
|
||||
//
|
||||
// https://dev.mysql.com/doc/refman/8.0/en/cast-functions.html#function_cast
|
||||
func (mysqlDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.CastExpression
|
||||
)
|
||||
|
||||
switch attr.Type.(type) {
|
||||
|
||||
case *dal.TypeNumber:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckNumber, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DECIMAL(65,10)")
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckFullISO8061, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "DATETIME")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
c = exp.NewCastExpression(drivers.BooleanCheck(val), "SIGNED")
|
||||
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckID, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "UNSIGNED")
|
||||
|
||||
case *dal.TypeTime:
|
||||
ce := exp.NewCaseExpression().
|
||||
When(drivers.RegexpLike(drivers.CheckTimeISO8061, val), val).
|
||||
Else(drivers.LiteralNULL)
|
||||
|
||||
c = exp.NewCastExpression(ce, "TIME")
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
}
|
||||
|
||||
func (mysqlDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (mysqlDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "BIGINT UNSIGNED"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT UNSIGNED"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "DATETIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "DECIMAL"
|
||||
// @todo precision, scale?
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "JSON"
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "JSON"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BLOB"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "TINYINT(1)"
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// target is the existing one
|
||||
func (mysqlDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint unsigned": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"bigint signed": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"bigint": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
|
||||
"decimal": true,
|
||||
},
|
||||
"datetime": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
},
|
||||
"time": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
|
||||
"datetime": true,
|
||||
},
|
||||
"date": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
|
||||
"datetime": true,
|
||||
},
|
||||
"decimal": {
|
||||
"double": true,
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"varchar": {
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
"char": true,
|
||||
},
|
||||
"text": {
|
||||
"varchar": true,
|
||||
"char": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"json": {},
|
||||
"blob": {},
|
||||
"tinyint": {
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
"decimal": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
"char": {
|
||||
"varchar": true,
|
||||
"text": true,
|
||||
"mediumtext": true,
|
||||
"longtext": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// Check varchar size
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0])
|
||||
|
||||
case assertName == "decimal" && targetName == "decimal":
|
||||
// Check numeric size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d mysqlDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
case "like", "nlike":
|
||||
for a := range args {
|
||||
args[a] = exp.NewLiteralExpression("LOWER(?)", args[a])
|
||||
}
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d mysqlDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
switch v := n.Value.V.(type) {
|
||||
case *expr.Boolean:
|
||||
// value handling for boolean is different in mysql
|
||||
// this was done to support value parsing for (IS ?) statement
|
||||
if cast.ToBool(v.Get()) {
|
||||
out = drivers.LiteralTRUE
|
||||
} else {
|
||||
out = drivers.LiteralFALSE
|
||||
}
|
||||
default:
|
||||
out = exp.NewLiteralExpression("?", n.Value.V.Get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d mysqlDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, _ exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
156
server/store/adapters/rdbms/drivers/mysql/dialect_test.go
Normal file
156
server/store/adapters/rdbms/drivers/mysql/dialect_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (text)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "decimal fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "decimal doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "decimal(2,3)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(42)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "varchar(84)",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "sneaking unsigned into signed",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint signed",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint unsigned",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "sneaking signed into unsigned",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint unsigned",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "bigint signed",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
d := mysqlDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
208
server/store/adapters/rdbms/drivers/mysql/information_schema.go
Normal file
208
server/store/adapters/rdbms/drivers/mysql/information_schema.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect().Where(
|
||||
exp.ParseIdentifier("TABLE_CATALOG").Eq("def"),
|
||||
// schema == dbname in MySQL world
|
||||
exp.ParseIdentifier("TABLE_SCHEMA").Eq(dbname),
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"IS_NULLABLE",
|
||||
"COLUMN_TYPE",
|
||||
).
|
||||
From("information_schema.columns").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ORDINAL_POSITION"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"TABLE_NAME"`
|
||||
Column string `db:"COLUMN_NAME"`
|
||||
IsNullable string `db:"IS_NULLABLE"`
|
||||
Type string `db:"COLUMN_TYPE"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: v.Type,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, dbname string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndexOnly = i.indexSelect().Where(
|
||||
exp.ParseIdentifier("TABLE_SCHEMA").Eq(dbname),
|
||||
exp.ParseIdentifier("INDEX_NAME").Eq(index),
|
||||
exp.ParseIdentifier("TABLE_NAME").Eq(table),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndexOnly); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"INDEX_NAME",
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"INDEX_TYPE",
|
||||
"EXPRESSION",
|
||||
"INDEX_COMMENT",
|
||||
).
|
||||
From("information_schema.statistics").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("TABLE_SCHEMA"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("INDEX_NAME"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("SEQ_IN_INDEX"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Name string `db:"INDEX_NAME"`
|
||||
Table string `db:"TABLE_NAME"`
|
||||
NonUnique bool `db:"NON_UNIQUE"`
|
||||
Type string `db:"INDEX_TYPE"`
|
||||
|
||||
// @todo there's also a "COMMENT" column?
|
||||
Comment string `db:"INDEX_COMMENT"`
|
||||
|
||||
Expression sql.NullString `db:"EXPRESSION"`
|
||||
ColumnName sql.NullString `db:"COLUMN_NAME"`
|
||||
ColumnSubPart sql.NullInt32 `db:"SUB_PART"`
|
||||
ColumnCollation sql.NullString `db:"COLLATION"`
|
||||
|
||||
// stats
|
||||
ColumnStatsCardinality int64 `db:"CARDINALITY"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, 0, 10)
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
if at, has = n2p[a.Name]; !has {
|
||||
out = append(out, &ddl.Index{
|
||||
Ident: a.Name,
|
||||
TableIdent: a.Table,
|
||||
Type: a.Type,
|
||||
Comment: a.Comment,
|
||||
Fields: make([]*ddl.IndexField, 0),
|
||||
Unique: !a.NonUnique,
|
||||
Predicate: "",
|
||||
Meta: nil,
|
||||
})
|
||||
n2p[a.Name] = p
|
||||
at = p
|
||||
}
|
||||
|
||||
col := &ddl.IndexField{
|
||||
Length: int(a.ColumnSubPart.Int32),
|
||||
Statistics: &ddl.IndexFieldStatistics{
|
||||
Cardinality: a.ColumnStatsCardinality,
|
||||
},
|
||||
}
|
||||
|
||||
switch a.ColumnCollation.String {
|
||||
case "A":
|
||||
col.Sort = dal.IndexFieldSortAsc
|
||||
case "D":
|
||||
col.Sort = dal.IndexFieldSortDesc
|
||||
}
|
||||
|
||||
if a.Expression.Valid {
|
||||
col.Expression = a.Expression.String
|
||||
} else {
|
||||
col.Column = a.ColumnName.String
|
||||
}
|
||||
|
||||
out[at].Fields = append(out[at].Fields, col)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
44
server/store/adapters/rdbms/drivers/mysql/json.go
Normal file
44
server/store/adapters/rdbms/drivers/mysql/json.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MySQL honours backslash escapes inside string literals so backslashes need to
|
||||
// be escaped alongside the quotes
|
||||
var jsonPathEscaper = strings.NewReplacer(`\`, `\\`, "'", `''`)
|
||||
|
||||
func jsonPathExpr(pp ...any) (exp.LiteralExpression, error) {
|
||||
if path, err := jsonPath(pp...); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return exp.NewLiteralExpression("'" + path + "'"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(jsonPathEscaper.Replace(path))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
50
server/store/adapters/rdbms/drivers/mysql/json_test.go
Normal file
50
server/store/adapters/rdbms/drivers/mysql/json_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
{
|
||||
input: []any{`tw\o`},
|
||||
path: `$.tw\\o`,
|
||||
},
|
||||
{
|
||||
input: []any{`tw\'o`},
|
||||
path: `$.tw\\''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
server/store/adapters/rdbms/drivers/mysql/ql.go
Normal file
16
server/store/adapters/rdbms/drivers/mysql/ql.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"std": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STD", args[0])
|
||||
},
|
||||
},
|
||||
}.ExprHandlers()
|
||||
)
|
||||
57
server/store/adapters/rdbms/drivers/mysql/types.go
Normal file
57
server/store/adapters/rdbms/drivers/mysql/types.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, 0)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
//func columnTypeTranslator(ct ddl.ColumnType) string {
|
||||
// switch ct.Type {
|
||||
// case ddl.ColumnTypeIdentifier:
|
||||
// return "BIGINT UNSIGNED"
|
||||
// case ddl.ColumnTypeBinary:
|
||||
// return "BLOB"
|
||||
// case ddl.ColumnTypeTimestamp:
|
||||
// return "DATETIME"
|
||||
// case ddl.ColumnTypeBoolean:
|
||||
// return "TINYINT(1)"
|
||||
// default:
|
||||
// return ddl.ColumnTypeTranslator(ct)
|
||||
// }
|
||||
//}
|
||||
111
server/store/adapters/rdbms/drivers/postgres/connect.go
Normal file
111
server/store/adapters/rdbms/drivers/postgres/connect.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "postgres"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
func init() {
|
||||
store.Register(Connect, SCHEMA, debugSchema)
|
||||
sql.Register(debugSchema, sqlmw.Driver(new(pq.Driver), instrumentation.Debug()))
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
func NewConfig(dsn string) (c *rdbms.ConnConfig, err error) {
|
||||
const (
|
||||
validScheme = "postgres"
|
||||
)
|
||||
var (
|
||||
scheme string
|
||||
u *url.URL
|
||||
)
|
||||
|
||||
if u, err = url.Parse(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres") {
|
||||
scheme = u.Scheme
|
||||
u.Scheme = validScheme
|
||||
} else {
|
||||
return nil, fmt.Errorf("expecting valid schema (postgres://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: scheme,
|
||||
DataSourceName: u.String(),
|
||||
DBName: strings.Trim(u.Path, "/"),
|
||||
MaskedDSN: u.Redacted(),
|
||||
}
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(*pq.Error); ok {
|
||||
switch implErr.Code.Name() {
|
||||
case "unique_violation":
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
37
server/store/adapters/rdbms/drivers/postgres/dal.go
Normal file
37
server/store/adapters/rdbms/drivers/postgres/dal.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
128
server/store/adapters/rdbms/drivers/postgres/data_definer.go
Normal file
128
server/store/adapters/rdbms/drivers/postgres/data_definer.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// dataDefiner for MySQL
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *postgresDialect
|
||||
}
|
||||
|
||||
reTypeColumn struct {
|
||||
Dialect *postgresDialect
|
||||
Table string
|
||||
Column string
|
||||
Type *ddl.ColumnType
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (*ddl.Table, error) {
|
||||
return ddl.ConvertModel(m, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{Table: t, Dialect: dd.d})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, "public", dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &reTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *reTypeColumn) ToSQL() (sql string, aa []interface{}, err error) {
|
||||
return fmt.Sprintf(
|
||||
`ALTER TABLE %s ALTER COLUMN %s TYPE %s`,
|
||||
c.Dialect.QuoteIdent(c.Table),
|
||||
c.Dialect.QuoteIdent(c.Column),
|
||||
c.Type.Name,
|
||||
), nil, nil
|
||||
}
|
||||
427
server/store/adapters/rdbms/drivers/postgres/dialect.go
Normal file
427
server/store/adapters/rdbms/drivers/postgres/dialect.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
postgresDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &postgresDialect{}
|
||||
|
||||
dialect = &postgresDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("postgres")
|
||||
goquDialectOptions = postgres.DialectOptions()
|
||||
quoteIdent = string(postgres.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
}
|
||||
)
|
||||
|
||||
func Dialect() *postgresDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (postgresDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (postgresDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (postgresDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (postgresDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d postgresDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("TO_JSON", expr)
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonExtract(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(true, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) JsonExtractUnquote(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(false, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
// When dealing with multi values, we need to make a cross join with every element in the array
|
||||
// to achieve the same functionality as we had before
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
xpr, err := t.AttributeExpressionQuoted(colName)
|
||||
if err != nil {
|
||||
q = q.SetError(err)
|
||||
return q
|
||||
}
|
||||
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("JSONB_ARRAY_ELEMENTS_TEXT", xpr).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares postgresql compatible comparison of value and JSON array
|
||||
//
|
||||
// literal value = multi-value field / plain
|
||||
// 'value' <@ (v->'f0')::JSONB
|
||||
//
|
||||
// single-value field = multi-value field / plain
|
||||
// v->'f1'->0 <@ (v->'f0')::JSONB
|
||||
func (d postgresDialect) JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error) {
|
||||
return exp.NewLiteralExpression("(?)::JSONB <@ (?)::JSONB", needle, haystack), nil
|
||||
}
|
||||
|
||||
func (d postgresDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d postgresDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeTime:
|
||||
return &TypeTime{c}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (expr exp.Expression, err error) {
|
||||
switch attr.Type.(type) {
|
||||
case *dal.TypeText:
|
||||
expr = exp.NewCastExpression(val, "TEXT")
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
// convert to text first
|
||||
expr = exp.NewCastExpression(val, "TEXT")
|
||||
|
||||
// compare to text representation of true
|
||||
expr = exp.NewBooleanExpression(exp.EqOp, expr, exp.NewLiteralExpression(`true::TEXT`))
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
identExpr := exp.NewIdentifierExpression("", modelIdent, ident)
|
||||
|
||||
// truncate timestamp data type to second mark precision
|
||||
if attr.Type.Type() == dal.AttributeTypeTimestamp {
|
||||
return exp.NewLiteralExpression("date_trunc(?, ?)", "second", identExpr), nil
|
||||
}
|
||||
|
||||
// using column directly
|
||||
return exp.NewLiteralExpression("?", identExpr), nil
|
||||
}
|
||||
|
||||
func (postgresDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID:
|
||||
col.Type.Name = "NUMERIC"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
case *dal.TypeRef:
|
||||
col.Type.Name = "NUMERIC"
|
||||
col.Default = ddl.DefaultID(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
|
||||
if t.Timezone {
|
||||
col.Type.Name += "TZ"
|
||||
}
|
||||
|
||||
if t.Precision >= 0 {
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *TypeTime:
|
||||
col.Type.Name = "TIME"
|
||||
|
||||
if t.Timezone {
|
||||
col.Type.Name += "TZ"
|
||||
}
|
||||
|
||||
if t.Precision >= 0 {
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "DATE"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
if numType := cast.ToString(t.Meta["rdbms:type"]); numType != "" {
|
||||
col.Type.Name = numType
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, 0, t.DefaultValue)
|
||||
break
|
||||
}
|
||||
|
||||
col.Type.Name = "NUMERIC"
|
||||
|
||||
switch {
|
||||
case t.Precision > 0 && t.Scale > 0:
|
||||
col.Type.Name += fmt.Sprintf("(%d, %d)", t.Precision, t.Scale)
|
||||
case t.Precision > 0:
|
||||
col.Type.Name += fmt.Sprintf("(%d)", t.Precision)
|
||||
}
|
||||
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "JSONB"
|
||||
if col.Default, err = ddl.DefaultJSON(t.HasDefault, t.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
// @todo geometry type
|
||||
col.Type.Name = "JSONB"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BYTEA"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BOOLEAN"
|
||||
col.Default = ddl.DefaultBoolean(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "UUID"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (postgresDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"numeric": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
// @note this isn't entirely correct, but record revision lapsus made us
|
||||
"integer": true,
|
||||
},
|
||||
"integer": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
"bigint": true,
|
||||
"numeric": true,
|
||||
},
|
||||
"timestamp": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
|
||||
"timestamptz": true,
|
||||
},
|
||||
"timestamptz": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"time": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
|
||||
"timetz": true,
|
||||
},
|
||||
"timetz": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"date": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
"text": {},
|
||||
"varchar": {
|
||||
"text": true,
|
||||
},
|
||||
"jsonb": {},
|
||||
"bytea": {},
|
||||
"boolean": {
|
||||
"numeric": true,
|
||||
},
|
||||
"uuid": {
|
||||
"text": true,
|
||||
"varchar": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := assertName == targetName || matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "varchar" && targetName == "varchar":
|
||||
// Check varchar size
|
||||
for i := len(assertMeta); i < 1; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 1; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0])
|
||||
|
||||
case assertName == "numeric" && targetName == "numeric":
|
||||
// Check numeric size and precision
|
||||
for i := len(assertMeta); i < 2; i++ {
|
||||
assertMeta = append(assertMeta, "0")
|
||||
}
|
||||
for i := len(targetMeta); i < 2; i++ {
|
||||
targetMeta = append(targetMeta, "0")
|
||||
}
|
||||
|
||||
return baseMatch && cast.ToInt(assertMeta[0]) <= cast.ToInt(targetMeta[0]) && cast.ToInt(assertMeta[1]) <= cast.ToInt(targetMeta[1])
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d postgresDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return castColumnDataToText("CONCAT", args...)
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
case "like", "nlike":
|
||||
if dalType, ok := n.Args[0].Meta["dal.Attribute"].(*dal.Attribute); ok {
|
||||
col, err := d.AttributeToColumn(dalType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if the type is id (numeric) data type, then cast it to text
|
||||
if col.Type.Name == "NUMERIC" {
|
||||
op := "LIKE"
|
||||
if ref == "nlike" {
|
||||
op = "NOT LIKE"
|
||||
}
|
||||
return castColumnDataToText(op, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d postgresDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
switch v := n.Value.V.(type) {
|
||||
case *expr.Boolean:
|
||||
// value handling for boolean is different in postgres
|
||||
// this was done to support value parsing for (IS $3) statement
|
||||
if cast.ToBool(v.Get()) {
|
||||
out = drivers.LiteralTRUE
|
||||
} else {
|
||||
out = drivers.LiteralFALSE
|
||||
}
|
||||
default:
|
||||
out = exp.NewLiteralExpression("?", n.Value.V.Get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d postgresDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, nst exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, nst)
|
||||
}
|
||||
|
||||
// castColumnDataToText converts column data to text.
|
||||
func castColumnDataToText(op string, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression(op, aa...), nil
|
||||
}
|
||||
156
server/store/adapters/rdbms/drivers/postgres/dialect_test.go
Normal file
156
server/store/adapters/rdbms/drivers/postgres/dialect_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColumnFits(t *testing.T) {
|
||||
tcc := []struct {
|
||||
name string
|
||||
target *ddl.Column
|
||||
assert *ddl.Column
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match (text)",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fits somewhere",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "numeric(1,2)",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "numeric(1,2)",
|
||||
},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{
|
||||
Name: "text",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "numeric fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "numeric doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(1,2)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "numeric(2,3)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar fits",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "varchar doesn't fit",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(42)"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "varchar(84)"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "timestamp fits into timestamptz",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamptz"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamp"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "timestamptz doesn't fit into timestamp",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamp"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timestamptz"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "time fits into timetz",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timetz"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "timetz doesn't fit into time",
|
||||
target: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "time"},
|
||||
},
|
||||
assert: &ddl.Column{
|
||||
Type: &ddl.ColumnType{Name: "timetz"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
d := postgresDialect{}
|
||||
|
||||
for _, c := range tcc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out := d.ColumnFits(c.target, c.assert)
|
||||
assert.Equal(t, c.expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, schema, dbname string) (*ddl.Table, error) {
|
||||
var (
|
||||
oneTable = i.columnSelect().Where(
|
||||
exp.ParseIdentifier("table_name").Eq(table),
|
||||
exp.ParseIdentifier("table_catalog").Eq(dbname),
|
||||
exp.ParseIdentifier("table_schema").Eq(schema),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanColumns(ctx, oneTable); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) columnSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"table_name",
|
||||
"column_name",
|
||||
"is_nullable",
|
||||
"data_type",
|
||||
// We'll use these two to get numeric's precision and scale
|
||||
"numeric_precision",
|
||||
"numeric_scale",
|
||||
// We'll use this one to get varchar length
|
||||
"character_maximum_length",
|
||||
).
|
||||
From("information_schema.columns").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("table_schema"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("ordinal_position"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanColumns(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Table, err error) {
|
||||
var (
|
||||
at int
|
||||
has bool
|
||||
n2p = make(map[string]int)
|
||||
// Making column type naming consistent between different databases
|
||||
typeMapping = map[string]string{
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"character varying": "varchar",
|
||||
}
|
||||
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"table_name"`
|
||||
Column string `db:"column_name"`
|
||||
IsNullable string `db:"is_nullable"`
|
||||
Type string `db:"data_type"`
|
||||
Precision any `db:"numeric_precision"`
|
||||
Scale any `db:"numeric_scale"`
|
||||
MaxLength any `db:"character_maximum_length"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Table, 0, 10)
|
||||
|
||||
for _, v := range aux {
|
||||
if at, has = n2p[v.Table]; !has {
|
||||
at = len(out)
|
||||
n2p[v.Table] = at
|
||||
out = append(out, &ddl.Table{Ident: v.Table})
|
||||
}
|
||||
|
||||
tn, has := typeMapping[v.Type]
|
||||
if !has {
|
||||
tn = v.Type
|
||||
}
|
||||
|
||||
if tn == "numeric" {
|
||||
var (
|
||||
p, s int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.Precision) {
|
||||
p = cast.ToInt(v.Precision)
|
||||
}
|
||||
if !reflect2.IsNil(v.Scale) {
|
||||
s = cast.ToInt(v.Scale)
|
||||
}
|
||||
|
||||
if p+s > 0 {
|
||||
tn = fmt.Sprintf("%s(%d,%d)", tn, p, s)
|
||||
}
|
||||
}
|
||||
|
||||
if tn == "varchar" {
|
||||
var (
|
||||
l int
|
||||
)
|
||||
|
||||
if !reflect2.IsNil(v.MaxLength) {
|
||||
l = cast.ToInt(v.MaxLength)
|
||||
}
|
||||
|
||||
tn = fmt.Sprintf("%s(%d)", tn, l)
|
||||
}
|
||||
|
||||
out[at].Columns = append(out[at].Columns, &ddl.Column{
|
||||
Ident: v.Column,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: tn,
|
||||
Null: v.IsNullable == "YES",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, schema string) (*ddl.Index, error) {
|
||||
var (
|
||||
oneIndex = i.indexSelect().Where(
|
||||
exp.ParseIdentifier("indexname").Eq(index),
|
||||
exp.ParseIdentifier("tablename").Eq(table),
|
||||
exp.ParseIdentifier("schemaname").Eq(schema),
|
||||
)
|
||||
)
|
||||
|
||||
if out, err := i.scanIndexes(ctx, oneIndex); err != nil {
|
||||
return nil, err
|
||||
} else if len(out) > 0 {
|
||||
return out[0], nil
|
||||
} else {
|
||||
return nil, errors.NotFound("index does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) indexSelect() *goqu.SelectDataset {
|
||||
return dialect.GOQU().Select(
|
||||
"tablename",
|
||||
"indexname",
|
||||
"indexdef",
|
||||
).
|
||||
From("pg_indexes").
|
||||
Order(
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("tablename"), exp.AscDir, exp.NoNullsSortType),
|
||||
exp.NewOrderedExpression(exp.ParseIdentifier("indexname"), exp.AscDir, exp.NoNullsSortType),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *informationSchema) scanIndexes(ctx context.Context, sd *goqu.SelectDataset) (out []*ddl.Index, err error) {
|
||||
var (
|
||||
// https://dev.mysql.com/doc/mysql-infoschema-excerpt/5.7/en/information-schema-statistics-table.html
|
||||
aux = make([]struct {
|
||||
Table string `db:"tablename"`
|
||||
Index string `db:"indexname"`
|
||||
Def string `db:"indexdef"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = ddl.Structs(ctx, i.conn, sd, &aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = make([]*ddl.Index, len(aux))
|
||||
|
||||
//// iterate over results (aux) and populate ddl.Index struct
|
||||
for i, a := range aux {
|
||||
out[i] = &ddl.Index{}
|
||||
if err = parseIndexDefinition(a.Def, out[i]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parses postgesql specific index definition
|
||||
func parseIndexDefinition(def string, index *ddl.Index) error {
|
||||
const (
|
||||
TOKEN_INDEX = " INDEX "
|
||||
TOKEN_ON = " ON "
|
||||
TOKEN_USING = " USING "
|
||||
TOKEN_UNIQUE = " UNIQUE INDEX "
|
||||
TOKEN_WHERE = " WHERE "
|
||||
)
|
||||
|
||||
// if def contains TOKEN_UNIQUE, consider this an unique index
|
||||
index.Unique = strings.Contains(def, TOKEN_UNIQUE)
|
||||
|
||||
// parse postgesql index definition
|
||||
tpi := strings.Index(def, TOKEN_INDEX)
|
||||
tpo := strings.Index(def, TOKEN_ON)
|
||||
tpu := strings.Index(def, TOKEN_USING)
|
||||
tpw := strings.Index(def, TOKEN_WHERE)
|
||||
|
||||
if tpw == -1 {
|
||||
tpw = len(def)
|
||||
}
|
||||
|
||||
// extract index name
|
||||
index.Ident = def[tpi+len(TOKEN_INDEX) : tpo]
|
||||
|
||||
// extract index table
|
||||
index.TableIdent = def[tpo+len(TOKEN_ON) : tpu]
|
||||
index.TableIdent = strings.SplitN(index.TableIdent, ".", 2)[1]
|
||||
|
||||
// extract index fields
|
||||
fieldsDef := def[tpu+len(TOKEN_USING) : tpw]
|
||||
|
||||
typeAndFields := strings.SplitN(fieldsDef, " ", 2)
|
||||
|
||||
index.Type = typeAndFields[0]
|
||||
|
||||
fields := strings.Split(typeAndFields[1][1:len(typeAndFields[1])-1], ", ")
|
||||
index.Fields = make([]*ddl.IndexField, len(fields))
|
||||
for i, fd := range fields {
|
||||
f := &ddl.IndexField{}
|
||||
|
||||
if strings.Contains(fd, "(") {
|
||||
f.Expression = fd
|
||||
} else {
|
||||
f.Column = fd
|
||||
}
|
||||
|
||||
// @todo direction/sorting
|
||||
index.Fields[i] = f
|
||||
}
|
||||
|
||||
// extract predicate/where
|
||||
if tpw != len(def) {
|
||||
index.Predicate = def[tpw+len(TOKEN_WHERE)+1 : len(def)-1]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_parseIndexDefinition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
def string
|
||||
index *ddl.Index
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple index",
|
||||
def: "CREATE INDEX users_email ON public.users USING btree (email)",
|
||||
index: &ddl.Index{
|
||||
Ident: "users_email",
|
||||
TableIdent: "users",
|
||||
Type: "btree",
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with predicate",
|
||||
def: "CREATE UNIQUE INDEX users_email ON public.users USING btree (email) WHERE (deleted_at IS NULL)",
|
||||
index: &ddl.Index{
|
||||
Ident: "users_email",
|
||||
TableIdent: "users",
|
||||
Type: "btree",
|
||||
Unique: true,
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "email",
|
||||
},
|
||||
},
|
||||
Predicate: "deleted_at IS NULL",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expressions with predicate",
|
||||
def: "CREATE UNIQUE INDEX unique_language_handle ON public.templates USING btree (language, lower((handle)::text)) WHERE ((length((handle)::text) > 0) AND (deleted_at IS NULL))",
|
||||
index: &ddl.Index{
|
||||
Ident: "unique_language_handle",
|
||||
TableIdent: "templates",
|
||||
Type: "btree",
|
||||
Unique: true,
|
||||
Fields: []*ddl.IndexField{
|
||||
{
|
||||
Column: "language",
|
||||
},
|
||||
{
|
||||
Expression: "lower((handle)::text)",
|
||||
},
|
||||
},
|
||||
Predicate: "(length((handle)::text) > 0) AND (deleted_at IS NULL)",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
index := &ddl.Index{}
|
||||
if err := parseIndexDefinition(tt.def, index); (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseIndexDefinition() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.index, index)
|
||||
})
|
||||
}
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/postgres/json.go
Normal file
42
server/store/adapters/rdbms/drivers/postgres/json.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeepIdentJSON constructs expression with chain of JSON operators
|
||||
// that point value inside JSON document
|
||||
//
|
||||
// https://www.postgresql.org/docs/9.3/functions-json.html
|
||||
func DeepIdentJSON(asJSON bool, jsonDoc exp.Expression, pp ...any) exp.LiteralExpression {
|
||||
var (
|
||||
sql strings.Builder
|
||||
last = len(pp) - 1
|
||||
)
|
||||
|
||||
sql.WriteString("?")
|
||||
|
||||
for i, p := range pp {
|
||||
sql.WriteString("-")
|
||||
|
||||
sql.WriteString(">")
|
||||
if i == last && !asJSON {
|
||||
sql.WriteString(">")
|
||||
}
|
||||
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString("'")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
sql.WriteString("'")
|
||||
case int:
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
default:
|
||||
panic("invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression(sql.String(), jsonDoc)
|
||||
}
|
||||
94
server/store/adapters/rdbms/drivers/postgres/json_test.go
Normal file
94
server/store/adapters/rdbms/drivers/postgres/json_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_DeepIdentJSON(t *testing.T) {
|
||||
var (
|
||||
pre = `SELECT `
|
||||
post = ` FROM "test"`
|
||||
|
||||
cc = []struct {
|
||||
input []interface{}
|
||||
sql string
|
||||
asJSON bool
|
||||
args []interface{}
|
||||
}{
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
sql: `"one"->>'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
sql: `"one"->2->>'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
sql: `"one"->'two'->>3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
asJSON: true,
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
asJSON: true,
|
||||
sql: `"one"->2->'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'->3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "tw'o"},
|
||||
sql: `"one"->>'tw''o'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
conv = func(asJSON bool, pp ...any) exp.SQLExpression {
|
||||
return goqu.Dialect("postgres").
|
||||
Select(DeepIdentJSON(asJSON, exp.ParseIdentifier(pp[0].(string)), pp[1:]...)).From("test")
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.sql, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
sql, args, err := conv(c.asJSON, c.input...).ToSQL()
|
||||
r.NoError(err)
|
||||
r.Equal(pre+c.sql+post, sql)
|
||||
r.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
192
server/store/adapters/rdbms/drivers/postgres/ql.go
Normal file
192
server/store/adapters/rdbms/drivers/postgres/ql.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
"concat": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// need to force text type on all arguments
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("CONCAT", aa...)
|
||||
},
|
||||
},
|
||||
"instr": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// need to force text type on all arguments
|
||||
aa := make([]any, len(args))
|
||||
for a := range args {
|
||||
aa[a] = exp.NewCastExpression(exp.NewLiteralExpression("?", args[a]), "TEXT")
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("INSTR", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// filtering
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("NOW")
|
||||
},
|
||||
},
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("QUARTER FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("YEAR FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("MONTH FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("?::TIMESTAMPTZ", args[0])
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("?::DATE", args[0])
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("DAY FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"week": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("EXTRACT",
|
||||
exp.NewLiteralExpression("WEEK FROM ?", args[0]),
|
||||
)
|
||||
},
|
||||
},
|
||||
"time": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("DATE_TRUNC('second', ?::TIME)::TIME", args[0])
|
||||
},
|
||||
},
|
||||
|
||||
// @todo replace given argument before constructing sql
|
||||
"date_format": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("TO_CHAR",
|
||||
exp.NewLiteralExpression("?::TIMESTAMPTZ", args[0]),
|
||||
exp.NewLiteralExpression("?::TEXT", translateDateFormatParam(args[1])),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
"interval": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// The problem here is that PGSQL, to my findings, doesn't have functions to add/sub dates
|
||||
// like MySQL for example.
|
||||
//
|
||||
// We need to construct an expression in the lines of `INTERVAL 'N UNIT'` which
|
||||
// then becomes, for example, d + INTERVAL 'N UNIT'.
|
||||
//
|
||||
// The problem #2 is that we can't just use value placeholders in string literals
|
||||
// nor is there a 2 arg function to make an interval. There is a make_interval function
|
||||
// but that one won't do.
|
||||
//
|
||||
// So...
|
||||
// (? || 'S') makes the interval label a plural because MySQL uses singular and that
|
||||
// is what QL supports. PgSQL uses plural, such as years, months, and days.
|
||||
//
|
||||
// The rest of the expression is just to construct the string which can then be casted to INTERVAL
|
||||
// which can then be used in date math, which is done with regular math operators.
|
||||
return exp.NewLiteralExpression("(?::INTEGER || ' ' || (? || 'S'))::INTERVAL", args[1], args[0])
|
||||
},
|
||||
},
|
||||
|
||||
"date_add": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(? + ?)", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
"date_sub": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(? - ?)", args[0], args[1])
|
||||
},
|
||||
},
|
||||
|
||||
// functions currently unsupported in PostgreSQL store backend
|
||||
// "STD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
}.ExprHandlers()
|
||||
)
|
||||
|
||||
func translateDateFormatParam(e interface{}) interface{} {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e
|
||||
}
|
||||
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
return dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func dateFormatReplacer(s string) string {
|
||||
return strings.NewReplacer(
|
||||
// @todo Doing ...%dT%H... (for iso timestamp) pgsql doesn't format it correctly
|
||||
// so I'm covering this edge case.
|
||||
// We should fix this properly when we redo record storage.
|
||||
`%dT%H`, `DD"T"HH24`,
|
||||
|
||||
`%a`, `Dy`,
|
||||
`%b`, `Mon`,
|
||||
`%c`, `FMMM`,
|
||||
`%d`, `DD`,
|
||||
`%e`, `FMDD`,
|
||||
`%f`, `US`,
|
||||
`%H`, `HH24`,
|
||||
`%h`, `HH12`,
|
||||
`%I`, `HH12`,
|
||||
`%i`, `MI`,
|
||||
`%j`, `DDD`,
|
||||
`%k`, `FMHH24`,
|
||||
`%l`, `FMHH12`,
|
||||
`%M`, `FMMonth`,
|
||||
`%m`, `MM`,
|
||||
`%p`, `AM`,
|
||||
`%r`, `HH12:MI:SS AM`,
|
||||
`%S`, `SS`,
|
||||
`%s`, `SS`,
|
||||
`%T`, `HH24:MI:SS`,
|
||||
`%W`, `FMDay`,
|
||||
`%Y`, `YYYY`,
|
||||
`%y`, `YY`,
|
||||
`%%`, `%`,
|
||||
).Replace(s)
|
||||
}
|
||||
88
server/store/adapters/rdbms/drivers/postgres/ql_test.go
Normal file
88
server/store/adapters/rdbms/drivers/postgres/ql_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @todo Ql functions should be under store/tests so it can be tested across all drivers along with generated tests.
|
||||
//
|
||||
// for now, Its test coverage is limited per driver.
|
||||
func TestConverter(t *testing.T) {
|
||||
const SELECT = "SELECT "
|
||||
var (
|
||||
conv = ql.Converter(ql.RefHandler(dialect.ExprHandler))
|
||||
|
||||
cases = []struct {
|
||||
qry string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
qry: `quarter('2022-07-21')`,
|
||||
sql: `EXTRACT(QUARTER FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `year('2022-07-21')`,
|
||||
sql: `EXTRACT(YEAR FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `month('2022-07-21')`,
|
||||
sql: `EXTRACT(MONTH FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `timestamp('2022-07-21')`,
|
||||
sql: `$1::TIMESTAMPTZ`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date('2022-07-21')`,
|
||||
sql: `$1::DATE`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `week('2022-07-21')`,
|
||||
sql: `EXTRACT(WEEK FROM $1)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `time('2022-07-21 12:41')`,
|
||||
sql: `DATE_TRUNC('second', $1::TIME)::TIME`,
|
||||
args: []any{"2022-07-21 12:41"},
|
||||
},
|
||||
{
|
||||
qry: `date_format('2022-07-21','%a')`,
|
||||
sql: `TO_CHAR($1::TIMESTAMPTZ, $2::TEXT)`,
|
||||
args: []any{"2022-07-21", "Dy"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.qry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
ee, err := conv.Parse(c.qry)
|
||||
req.NoError(err)
|
||||
|
||||
sql, args, err := dialect.GOQU().Select(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
|
||||
p := strings.Index(sql, SELECT)
|
||||
req.Zero(p)
|
||||
|
||||
sql = sql[p+len(SELECT):]
|
||||
|
||||
req.Equal(c.sql, sql)
|
||||
req.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
42
server/store/adapters/rdbms/drivers/postgres/types.go
Normal file
42
server/store/adapters/rdbms/drivers/postgres/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
)
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
//parsed, err := time.Parse(drivers.TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
//if err != nil {
|
||||
// return time.Time{}, false, err
|
||||
//}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(t.Timezone, t.Precision)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
174
server/store/adapters/rdbms/drivers/sqlite/connect.go
Normal file
174
server/store/adapters/rdbms/drivers/sqlite/connect.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/instrumentation"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/ngrok/sqlmw"
|
||||
)
|
||||
|
||||
const (
|
||||
// base for our schemas
|
||||
SCHEMA = "sqlite3"
|
||||
|
||||
// alternative s hema with custom driver
|
||||
altSchema = SCHEMA + "+alt"
|
||||
|
||||
// debug schema with verbose logging
|
||||
debugSchema = SCHEMA + "+debug"
|
||||
)
|
||||
|
||||
var (
|
||||
// make a custom driver with REGEX support
|
||||
customDriver = &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) (err error) {
|
||||
// register regexp function and use Go's regexp fn
|
||||
if err = conn.RegisterFunc("regexp", fnRegExp, true); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.RegisterFunc("json_array_contains", sqliteFuncJsonArrayContains, true); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func fnRegExp(pattern string, s string) (matched bool, err error) {
|
||||
matched, err = regexp.MatchString(pattern, s)
|
||||
return
|
||||
}
|
||||
|
||||
func init() {
|
||||
// register alter driver
|
||||
sql.Register(altSchema, customDriver)
|
||||
// register debug driver
|
||||
sql.Register(debugSchema, sqlmw.Driver(customDriver, instrumentation.Debug()))
|
||||
|
||||
store.Register(Connect, SCHEMA, altSchema, debugSchema)
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (_ store.Storer, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s := &rdbms.Store{
|
||||
DB: db,
|
||||
|
||||
DAL: dal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)),
|
||||
|
||||
Dialect: Dialect(),
|
||||
ErrorHandler: errorHandler,
|
||||
|
||||
TxRetryLimit: -1,
|
||||
DataDefiner: DataDefiner(cfg.DBName, db),
|
||||
|
||||
Ping: db.PingContext,
|
||||
}
|
||||
|
||||
s.SetDefaults()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ConnectInMemory(ctx context.Context) (s store.Storer, err error) {
|
||||
return Connect(ctx, altSchema+"://file::memory:?cache=shared&mode=memory")
|
||||
}
|
||||
|
||||
func ConnectInMemoryWithDebug(ctx context.Context) (s store.Storer, err error) {
|
||||
return Connect(ctx, debugSchema+"://file::memory:?cache=shared&mode=memory")
|
||||
}
|
||||
|
||||
// NewConfig validates given DSN and ensures
|
||||
// params are present and correct
|
||||
func NewConfig(in string) (*rdbms.ConnConfig, error) {
|
||||
const (
|
||||
schemaDel = "://"
|
||||
)
|
||||
|
||||
var (
|
||||
c = &rdbms.ConnConfig{
|
||||
DriverName: altSchema,
|
||||
}
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(in, SCHEMA+schemaDel), strings.HasPrefix(in, altSchema+schemaDel):
|
||||
// no special handling
|
||||
case strings.HasPrefix(in, debugSchema+schemaDel):
|
||||
c.DriverName = debugSchema
|
||||
default:
|
||||
return nil, fmt.Errorf("expecting valid schema (sqlite3://) at the beginning of the DSN")
|
||||
}
|
||||
|
||||
// reassemble DSN with base schema
|
||||
c.DataSourceName = in[strings.Index(in, schemaDel)+len(schemaDel):]
|
||||
|
||||
// Nothing to mask for sqlite3 DSN
|
||||
c.MaskedDSN = c.DataSourceName
|
||||
|
||||
// Set to zero
|
||||
// Otherwise SQLite (in-memory) disconnects
|
||||
// and all tables and data is lost
|
||||
c.ConnMaxLifetime = 0
|
||||
|
||||
c.SetDefaults()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Transactions are disabled on SQLite
|
||||
//func txRetryErrHandler(try int, err error) bool {
|
||||
// for errors.Unwrap(err) != nil {
|
||||
// err = errors.Unwrap(err)
|
||||
// }
|
||||
//
|
||||
// var sqliteErr, ok = err.(sqlite3.Error)
|
||||
// if !ok {
|
||||
// return false
|
||||
// }
|
||||
//
|
||||
// switch sqliteErr.Code {
|
||||
// case sqlite3.ErrLocked:
|
||||
// return true
|
||||
//
|
||||
// }
|
||||
//
|
||||
// return false
|
||||
//}
|
||||
|
||||
func errorHandler(err error) error {
|
||||
if err != nil {
|
||||
if implErr, ok := err.(sqlite3.Error); ok {
|
||||
switch implErr.ExtendedCode {
|
||||
case sqlite3.ErrConstraintUnique:
|
||||
return store.ErrNotUnique.Wrap(implErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
37
server/store/adapters/rdbms/drivers/sqlite/dal.go
Normal file
37
server/store/adapters/rdbms/drivers/sqlite/dal.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
rdbmsdal "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
dal.RegisterConnector(dalConnector, SCHEMA, altSchema, debugSchema)
|
||||
}
|
||||
|
||||
func dalConnector(ctx context.Context, dsn string) (_ dal.Connection, err error) {
|
||||
var (
|
||||
db *sqlx.DB
|
||||
cfg *rdbms.ConnConfig
|
||||
)
|
||||
|
||||
if cfg, err = NewConfig(dsn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo rework the config building a bit; this will do for now
|
||||
if cfg.ConnTryMax >= 99 {
|
||||
cfg.ConnTryMax = 2
|
||||
}
|
||||
|
||||
if db, err = rdbms.Connect(ctx, logger.Default(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return rdbmsdal.Connection(db, Dialect(), DataDefiner(cfg.DBName, db)), nil
|
||||
}
|
||||
114
server/store/adapters/rdbms/drivers/sqlite/data_definer.go
Normal file
114
server/store/adapters/rdbms/drivers/sqlite/data_definer.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// @todo this is unmodified copy of postgres's data definer struct!
|
||||
dataDefiner struct {
|
||||
dbName string
|
||||
conn *sqlx.DB
|
||||
is *informationSchema
|
||||
d *sqliteDialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ ddl.DataDefiner = new(dataDefiner)
|
||||
)
|
||||
|
||||
func DataDefiner(dbName string, conn *sqlx.DB) *dataDefiner {
|
||||
return &dataDefiner{
|
||||
dbName: dbName,
|
||||
conn: conn,
|
||||
is: InformationSchema(conn),
|
||||
d: Dialect(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertModel(m *dal.Model) (*ddl.Table, error) {
|
||||
return ddl.ConvertModel(m, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ConvertAttribute(attr *dal.Attribute) (*ddl.Column, error) {
|
||||
return ddl.ConvertAttribute(attr, dd.d)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableCreate(ctx context.Context, t *ddl.Table) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableDrop(ctx context.Context, t string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropTable{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) TableLookup(ctx context.Context, t string) (*ddl.Table, error) {
|
||||
return dd.is.TableLookup(ctx, t, "public", dd.dbName)
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnAdd(ctx context.Context, t string, c *ddl.Column) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.AddColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: c,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnDrop(ctx context.Context, t, col string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnRename(ctx context.Context, t string, o string, n string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.RenameColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Old: o,
|
||||
New: n,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) ColumnReType(ctx context.Context, t string, col string, tp *ddl.ColumnType) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.ReTypeColumn{
|
||||
Dialect: dd.d,
|
||||
Table: t,
|
||||
Column: col,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexLookup(ctx context.Context, i, t string) (*ddl.Index, error) {
|
||||
if index, err := dd.is.IndexLookup(ctx, i, t, dd.dbName); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexCreate(ctx context.Context, t string, i *ddl.Index) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.CreateIndex{
|
||||
Dialect: dd.d,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
func (dd *dataDefiner) IndexDrop(ctx context.Context, t, i string) error {
|
||||
return ddl.Exec(ctx, dd.conn, &ddl.DropIndex{
|
||||
Dialect: dd.d,
|
||||
Ident: i,
|
||||
})
|
||||
}
|
||||
338
server/store/adapters/rdbms/drivers/sqlite/dialect.go
Normal file
338
server/store/adapters/rdbms/drivers/sqlite/dialect.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/cast2"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/doug-martin/goqu/v9/sqlgen"
|
||||
)
|
||||
|
||||
type (
|
||||
sqliteDialect struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
_ drivers.Dialect = &sqliteDialect{}
|
||||
|
||||
dialect = &sqliteDialect{}
|
||||
goquDialectWrapper = goqu.Dialect("sqlite3")
|
||||
goquDialectOptions *sqlgen.SQLDialectOptions
|
||||
quoteIdent = string(sqlite3.DialectOptions().QuoteRune)
|
||||
|
||||
nuances = drivers.Nuances{
|
||||
HavingClauseMustUseAlias: true,
|
||||
|
||||
ExpandedJsonColumnSelector: func(ident string) exp.Expression {
|
||||
return exp.NewLiteralExpression(fmt.Sprintf(`%s.value`, ident))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
goquDialectOptions = sqlite3.DialectOptions()
|
||||
|
||||
// https://github.com/doug-martin/goqu/v9/pull/330
|
||||
goquDialectOptions.TruncateClause = []byte("DELETE FROM")
|
||||
|
||||
// Overriding vanila SQLite dialect
|
||||
goqu.RegisterDialect("sqlite3", goquDialectOptions)
|
||||
}
|
||||
|
||||
func Dialect() *sqliteDialect {
|
||||
return dialect
|
||||
}
|
||||
|
||||
func (sqliteDialect) Nuances() drivers.Nuances {
|
||||
return nuances
|
||||
}
|
||||
|
||||
func (d sqliteDialect) AggregateBase(t drivers.TableCodec, groupBy []dal.AggregateAttr, out []dal.AggregateAttr) (slct *goqu.SelectDataset) {
|
||||
var (
|
||||
cols = t.Columns()
|
||||
|
||||
// working around a bug inside goqu lib that adds
|
||||
// * to the list of columns to be selected
|
||||
// even if we clear the columns first
|
||||
q = d.GOQU().
|
||||
From(t.Ident())
|
||||
)
|
||||
|
||||
for _, g := range groupBy {
|
||||
// Special handling for multi value fields
|
||||
if g.MultiValue {
|
||||
// Only straight up columns can be multi value so we can freely use RawExpr
|
||||
colName := g.RawExpr
|
||||
q = q.From(
|
||||
t.Ident(),
|
||||
goqu.Func("json_each",
|
||||
goqu.C("values").Table("compose_record"),
|
||||
exp.NewLiteralExpression(fmt.Sprintf(`'$.%s'`, colName)),
|
||||
).As(colName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
|
||||
}
|
||||
|
||||
q = q.Select(t.Ident().Col(cols[0].Name()))
|
||||
for _, col := range cols[1:] {
|
||||
q = q.SelectAppend(t.Ident().Col(col.Name()))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func (sqliteDialect) GOQU() goqu.DialectWrapper { return goquDialectWrapper }
|
||||
func (sqliteDialect) DialectOptions() *sqlgen.SQLDialectOptions { return goquDialectOptions }
|
||||
func (sqliteDialect) QuoteIdent(i string) string { return quoteIdent + i + quoteIdent }
|
||||
|
||||
func (d sqliteDialect) IndexFieldModifiers(attr *dal.Attribute, mm ...dal.IndexFieldModifier) (string, error) {
|
||||
return drivers.IndexFieldModifiers(attr, d.QuoteIdent, mm...)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) JsonQuote(expr exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("JSON_QUOTE", expr)
|
||||
}
|
||||
|
||||
func (sqliteDialect) JsonExtract(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(true, ident, pp...), nil
|
||||
}
|
||||
|
||||
func (sqliteDialect) JsonExtractUnquote(ident exp.Expression, pp ...any) (exp.Expression, error) {
|
||||
return DeepIdentJSON(false, ident, pp...), nil
|
||||
}
|
||||
|
||||
// JsonArrayContains prepares SQLite compatible comparison of value and JSON array
|
||||
//
|
||||
// # literal value = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
//
|
||||
// 'aaa' in (select value from json_each(v->'f2'))
|
||||
//
|
||||
// # single-value field = multi-value field / plain
|
||||
// # multi-value field = single-value field / plain
|
||||
// json_extract(v, '$.f3[0]') in (select value from json_each(v->'f2'));
|
||||
//
|
||||
// Unfortunately SQLite converts boolean values into 0 and 1 when decoding from
|
||||
// JSON and we need a special handler for that.
|
||||
func (sqliteDialect) JsonArrayContains(needle, haystack exp.Expression) (exp.Expression, error) {
|
||||
return exp.NewLiteralExpression("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", haystack, needle), nil
|
||||
}
|
||||
|
||||
func (d sqliteDialect) TableCodec(m *dal.Model) drivers.TableCodec {
|
||||
return drivers.NewTableCodec(m, d)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) TypeWrap(dt dal.Type) drivers.Type {
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
|
||||
// Any exception to general type-wrap implementation in the drivers package
|
||||
// should be placed here
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeDate:
|
||||
return &TypeDate{c}
|
||||
case *dal.TypeTimestamp:
|
||||
return &TypeTimestamp{c}
|
||||
}
|
||||
|
||||
return drivers.TypeWrap(dt)
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeCast(attr *dal.Attribute, val exp.Expression) (exp.Expression, error) {
|
||||
var (
|
||||
c exp.Expression
|
||||
)
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeTimestamp:
|
||||
// we have way of handling TZ here (not sure how to do that with strftime() function)
|
||||
// if we use %Z at the end strftime will always return NULL
|
||||
if t.Precision == 0 {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%dT%H:%M:%S", val)
|
||||
} else {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%dT%H:%M:%f", val)
|
||||
}
|
||||
|
||||
case *dal.TypeTime:
|
||||
// we have way of handling TZ here (not sure how to do that with strftime() function)
|
||||
// if we use %Z at the end strftime will always return NULL
|
||||
if t.Precision == 0 {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%H:%M:%S", val)
|
||||
} else {
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%H:%M:%f", val)
|
||||
}
|
||||
|
||||
case *dal.TypeDate:
|
||||
c = exp.NewSQLFunctionExpression("strftime", "%Y-%m-%d", val)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
c = exp.NewCastExpression(val, "NUMERIC")
|
||||
|
||||
default:
|
||||
return drivers.AttributeCast(attr, val)
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", c), nil
|
||||
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeExpression(attr *dal.Attribute, modelIdent string, ident string) (expr exp.Expression, err error) {
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", modelIdent, ident)), nil
|
||||
}
|
||||
|
||||
func (sqliteDialect) AttributeToColumn(attr *dal.Attribute) (col *ddl.Column, err error) {
|
||||
|
||||
col = &ddl.Column{
|
||||
Ident: attr.StoreIdent(),
|
||||
Comment: attr.Label,
|
||||
Type: &ddl.ColumnType{
|
||||
Null: attr.Type.IsNullable(),
|
||||
},
|
||||
}
|
||||
|
||||
switch t := attr.Type.(type) {
|
||||
case *dal.TypeID, *dal.TypeRef:
|
||||
col.Type.Name = "BIGINT"
|
||||
|
||||
case *dal.TypeTimestamp:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeTime:
|
||||
col.Type.Name = "TIMESTAMP"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeDate:
|
||||
col.Type.Name = "TEXT"
|
||||
col.Default = ddl.DefaultValueCurrentTimestamp(t.DefaultCurrentTimestamp)
|
||||
|
||||
case *dal.TypeNumber:
|
||||
col.Type.Name = "NUMERIC"
|
||||
// @todo precision, scale? x
|
||||
col.Default = ddl.DefaultNumber(t.HasDefault, t.Precision, t.DefaultValue)
|
||||
|
||||
case *dal.TypeText:
|
||||
if t.Length > 0 {
|
||||
col.Type.Name = fmt.Sprintf("VARCHAR(%d)", t.Length)
|
||||
} else {
|
||||
col.Type.Name = "TEXT"
|
||||
}
|
||||
|
||||
if t.HasDefault {
|
||||
// @todo use proper quote type
|
||||
col.Default = fmt.Sprintf("%q", t.DefaultValue)
|
||||
}
|
||||
|
||||
case *dal.TypeJSON:
|
||||
col.Type.Name = "TEXT"
|
||||
if col.Default, err = ddl.DefaultJSON(t.HasDefault, t.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case *dal.TypeGeometry:
|
||||
col.Type.Name = "TEXT"
|
||||
|
||||
case *dal.TypeBlob:
|
||||
col.Type.Name = "BLOB"
|
||||
|
||||
case *dal.TypeBoolean:
|
||||
col.Type.Name = "BOOLEAN"
|
||||
col.Default = ddl.DefaultBoolean(t.HasDefault, t.DefaultValue)
|
||||
|
||||
case *dal.TypeUUID:
|
||||
col.Type.Name = "CHAR(36)"
|
||||
|
||||
case *dal.TypeEnum:
|
||||
col.Type.Name = "TEXT"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported column type: %s ", t.Type())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (sqliteDialect) ColumnFits(target, assert *ddl.Column) bool {
|
||||
targetType, targetName, targetMeta := ddl.ParseColumnTypes(target)
|
||||
assertType, assertName, assertMeta := ddl.ParseColumnTypes(assert)
|
||||
|
||||
// If everything matches up perfectly use that
|
||||
if assertType == targetType {
|
||||
return true
|
||||
}
|
||||
|
||||
// See if we can guess it
|
||||
// [the type of the target column][what types fit the target col. type]
|
||||
matches := map[string]map[string]bool{
|
||||
"bigint": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"timestamp": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"text": {
|
||||
"char": true,
|
||||
},
|
||||
"numeric": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
},
|
||||
"blob": {},
|
||||
"boolean": {
|
||||
"text": true,
|
||||
"char": true,
|
||||
"bigint": true,
|
||||
"numeric": true,
|
||||
},
|
||||
"char": {
|
||||
"text": true,
|
||||
},
|
||||
}
|
||||
|
||||
baseMatch := matches[assertName][targetName]
|
||||
|
||||
// Special cases
|
||||
switch {
|
||||
case assertName == "char" && targetName == "char":
|
||||
// Check char size
|
||||
return baseMatch && assertMeta[0] <= targetMeta[0]
|
||||
}
|
||||
|
||||
return baseMatch
|
||||
}
|
||||
|
||||
func (d sqliteDialect) ExprHandler(n *ql.ASTNode, args ...exp.Expression) (expr exp.Expression, err error) {
|
||||
switch ref := strings.ToLower(n.Ref); ref {
|
||||
case "concat":
|
||||
return exp.NewLiteralExpression("?"+strings.Repeat(" || ?", len(args)-1), cast2.Anys(args...)...), nil
|
||||
|
||||
case "in":
|
||||
return drivers.OpHandlerIn(d, n, args...)
|
||||
|
||||
case "nin":
|
||||
return drivers.OpHandlerNotIn(d, n, args...)
|
||||
|
||||
}
|
||||
|
||||
return ref2exp.RefHandler(n, args...)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) ValHandler(n *ql.ASTNode) (out exp.Expression, err error) {
|
||||
return ql.DefaultValueHandler(n)
|
||||
}
|
||||
|
||||
func (d sqliteDialect) OrderedExpression(expr exp.Expression, dir exp.SortDirection, nst exp.NullSortType) exp.OrderedExpression {
|
||||
return exp.NewOrderedExpression(expr, dir, exp.NoNullsSortType)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
// @todo this is unmodified copy of mysql's information schema struct!
|
||||
informationSchema struct {
|
||||
conn *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
func InformationSchema(conn *sqlx.DB) *informationSchema {
|
||||
return &informationSchema{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *informationSchema) TableLookup(ctx context.Context, table, schema, dbname string) (tbl *ddl.Table, err error) {
|
||||
var (
|
||||
// https://www.sqlite.org/pragma.html
|
||||
query = fmt.Sprintf(`PRAGMA table_info(%s)`, Dialect().QuoteIdent(table))
|
||||
aux = make([]struct {
|
||||
CID any `db:"cid"`
|
||||
Name string `db:"name"`
|
||||
Type string `db:"type"`
|
||||
NotNull bool `db:"notnull"`
|
||||
DefaultValue sql.NullString `db:"dflt_value"`
|
||||
PrimaryKey int `db:"pk"`
|
||||
}, 0)
|
||||
)
|
||||
|
||||
if err = sqlx.SelectContext(ctx, i.conn, &aux, query); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(aux) == 0 {
|
||||
return nil, errors.NotFound("table does not exist")
|
||||
}
|
||||
|
||||
tbl = &ddl.Table{
|
||||
Ident: table,
|
||||
Columns: make([]*ddl.Column, len(aux)),
|
||||
}
|
||||
|
||||
// iterate over results (aux) and populate ddl.Index struct
|
||||
for p, a := range aux {
|
||||
tbl.Columns[p] = &ddl.Column{
|
||||
Ident: a.Name,
|
||||
Type: &ddl.ColumnType{
|
||||
Name: a.Type,
|
||||
Null: !a.NotNull,
|
||||
},
|
||||
Default: a.DefaultValue.String,
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (i *informationSchema) IndexLookup(ctx context.Context, index, table, schema string) (*ddl.Index, error) {
|
||||
// for now, there is no need do implement this
|
||||
return nil, errors.NotFound("index %q not found", index)
|
||||
}
|
||||
104
server/store/adapters/rdbms/drivers/sqlite/json.go
Normal file
104
server/store/adapters/rdbms/drivers/sqlite/json.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/valyala/fastjson"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeepIdentJSON constructs expression with chain of JSON operators
|
||||
// that point value inside JSON document
|
||||
//
|
||||
// https://www.postgresql.org/docs/9.3/functions-json.html
|
||||
func DeepIdentJSON(asJSON bool, jsonDoc exp.Expression, pp ...any) exp.LiteralExpression {
|
||||
var (
|
||||
sql strings.Builder
|
||||
last = len(pp) - 1
|
||||
)
|
||||
|
||||
sql.WriteString("?")
|
||||
|
||||
for i, p := range pp {
|
||||
sql.WriteString("-")
|
||||
|
||||
sql.WriteString(">")
|
||||
if i == last && !asJSON {
|
||||
sql.WriteString(">")
|
||||
}
|
||||
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString("'")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
sql.WriteString("'")
|
||||
case int:
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
default:
|
||||
panic("invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression(sql.String(), jsonDoc)
|
||||
}
|
||||
|
||||
func jsonPath(pp ...any) (string, error) {
|
||||
var (
|
||||
sql strings.Builder
|
||||
)
|
||||
|
||||
sql.WriteString(`$`)
|
||||
|
||||
for _, p := range pp {
|
||||
switch path := p.(type) {
|
||||
case string:
|
||||
sql.WriteString(".")
|
||||
sql.WriteString(strings.ReplaceAll(path, "'", `''`))
|
||||
case int:
|
||||
sql.WriteString("[")
|
||||
sql.WriteString(strconv.Itoa(path))
|
||||
sql.WriteString("]")
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected path part (%q) type: %T", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.String(), nil
|
||||
}
|
||||
|
||||
func sqliteFuncJsonArrayContains(needle, haystack []byte) (_ bool, err error) {
|
||||
var n, h, i *fastjson.Value
|
||||
|
||||
if h, err = fastjson.ParseBytes(haystack); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.Type() != fastjson.TypeArray {
|
||||
err = fmt.Errorf("haystack is %s, expecting array", h.Type())
|
||||
return
|
||||
}
|
||||
|
||||
if n, err = fastjson.ParseBytes(needle); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, i = range h.GetArray() {
|
||||
if i.Type() != n.Type() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch i.Type() {
|
||||
case fastjson.TypeFalse, fastjson.TypeTrue:
|
||||
if i.GetBool() == n.GetBool() {
|
||||
return true, nil
|
||||
}
|
||||
case fastjson.TypeNumber, fastjson.TypeString:
|
||||
if i.String() == n.String() {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
129
server/store/adapters/rdbms/drivers/sqlite/json_test.go
Normal file
129
server/store/adapters/rdbms/drivers/sqlite/json_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
||||
)
|
||||
|
||||
// test deep ident expression generator
|
||||
func Test_DeepIdentJSON(t *testing.T) {
|
||||
var (
|
||||
pre = `SELECT `
|
||||
post = ` FROM "test"`
|
||||
|
||||
cc = []struct {
|
||||
input []interface{}
|
||||
sql string
|
||||
asJSON bool
|
||||
args []interface{}
|
||||
}{
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
sql: `"one"->>'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
sql: `"one"->2->>'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
sql: `"one"->'two'->>3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one"},
|
||||
asJSON: true,
|
||||
sql: `"one"`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two"},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", 2, "three"},
|
||||
asJSON: true,
|
||||
sql: `"one"->2->'three'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "two", 3},
|
||||
asJSON: true,
|
||||
sql: `"one"->'two'->3`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
{
|
||||
input: []interface{}{"one", "tw'o"},
|
||||
sql: `"one"->>'tw''o'`,
|
||||
args: []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
conv = func(asJSON bool, pp ...any) exp.SQLExpression {
|
||||
return goqu.Dialect("postgres").
|
||||
Select(DeepIdentJSON(asJSON, exp.ParseIdentifier(pp[0].(string)), pp[1:]...)).From("test")
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.sql, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
sql, args, err := conv(c.asJSON, c.input...).ToSQL()
|
||||
r.NoError(err)
|
||||
r.Equal(pre+c.sql+post, sql)
|
||||
r.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// test JSON path generator
|
||||
func Test_jsonPath(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
input []any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
input: []any{"one"},
|
||||
path: `$.one`,
|
||||
},
|
||||
{
|
||||
input: []any{"one", 2, "three"},
|
||||
path: `$.one[2].three`,
|
||||
},
|
||||
{
|
||||
input: []any{"tw'o"},
|
||||
path: `$.tw''o`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
var (
|
||||
r = require.New(t)
|
||||
)
|
||||
|
||||
path, err := jsonPath(c.input...)
|
||||
r.NoError(err)
|
||||
r.Equal(c.path, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
157
server/store/adapters/rdbms/drivers/sqlite/ql.go
Normal file
157
server/store/adapters/rdbms/drivers/sqlite/ql.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
var (
|
||||
ref2exp = ql.ExprHandlerMap{
|
||||
// filtering
|
||||
"now": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATE",
|
||||
exp.NewLiteralExpression("'NOW'"),
|
||||
)
|
||||
},
|
||||
},
|
||||
"quarter": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewLiteralExpression("(CAST(STRFTIME('%m', ?) AS INTEGER) + 2) / 3", args[0])
|
||||
},
|
||||
},
|
||||
"year": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%Y'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"month": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%m'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"date": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%Y-%m-%dT00:00:00Z'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"day": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%d'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"week": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
exp.NewLiteralExpression("'%W'"),
|
||||
args[0],
|
||||
)
|
||||
},
|
||||
},
|
||||
"datetime": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATETIME", args[0])
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
Handler: func(args ...exp.Expression) exp.Expression {
|
||||
return exp.NewSQLFunctionExpression("DATETIME", args[0])
|
||||
},
|
||||
},
|
||||
"date_format": {
|
||||
HandlerE: func(args ...exp.Expression) (exp.Expression, error) {
|
||||
format, err := supportedDateFormatParams(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return exp.NewSQLFunctionExpression("STRFTIME",
|
||||
format,
|
||||
args[0],
|
||||
), nil
|
||||
},
|
||||
},
|
||||
|
||||
// functions currently unsupported in SQLite store backend
|
||||
// "DATE_ADD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
// "DATE_SUB": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
// "STD": {
|
||||
// Handler: func(args ...exp.Expression) exp.Expression {
|
||||
// return exp.NewLiteralExpression("")
|
||||
// },
|
||||
// },
|
||||
}.ExprHandlers()
|
||||
|
||||
supportedSubstitutions = map[string]bool{
|
||||
"d": true,
|
||||
"H": true,
|
||||
"j": true,
|
||||
"m": true,
|
||||
"M": true,
|
||||
"S": true,
|
||||
"w": true,
|
||||
"W": true,
|
||||
"Y": true,
|
||||
"%": true,
|
||||
}
|
||||
)
|
||||
|
||||
func supportedDateFormatParams(e interface{}) (interface{}, error) {
|
||||
le, ok := e.(exp.LiteralExpression)
|
||||
if !ok {
|
||||
return e, fmt.Errorf("unknown date format")
|
||||
}
|
||||
|
||||
var format string
|
||||
args := le.Args()
|
||||
if len(args) > 0 {
|
||||
format = dateFormatReplacer(fmt.Sprintf("%s", args[0]))
|
||||
} else {
|
||||
return e, fmt.Errorf("date format not found")
|
||||
}
|
||||
|
||||
r := regexp.MustCompile(`%(?P<sub>.)`)
|
||||
|
||||
for _, m := range r.FindAllStringSubmatch(format, -1) {
|
||||
if len(m) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := supportedSubstitutions[m[1]]; !ok {
|
||||
return e, fmt.Errorf("format substitution not supported: %%%s", m[1])
|
||||
}
|
||||
}
|
||||
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func dateFormatReplacer(format string) string {
|
||||
return strings.NewReplacer(
|
||||
`%i`, `%M`,
|
||||
`%U`, `%W`,
|
||||
).Replace(format)
|
||||
}
|
||||
91
server/store/adapters/rdbms/drivers/sqlite/ql_test.go
Normal file
91
server/store/adapters/rdbms/drivers/sqlite/ql_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @todo Ql functions should be under store/tests so it can be tested across all drivers along with generated tests.
|
||||
// for now, Its test coverage is limited per driver.
|
||||
func TestConverter(t *testing.T) {
|
||||
const SELECT = "SELECT "
|
||||
var (
|
||||
conv = ql.Converter(ql.RefHandler(dialect.ExprHandler))
|
||||
|
||||
cases = []struct {
|
||||
qry string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
qry: `now()`,
|
||||
sql: `DATE('NOW')`,
|
||||
args: []any{},
|
||||
},
|
||||
{
|
||||
qry: `quarter('2022-07-21')`,
|
||||
sql: `(CAST(STRFTIME('%m', ?) AS INTEGER) + 2) / 3`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `year('2022-07-21')`,
|
||||
sql: `STRFTIME('%Y', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `month('2022-07-21')`,
|
||||
sql: `STRFTIME('%m', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date('2022-07-21')`,
|
||||
sql: `STRFTIME('%Y-%m-%dT00:00:00Z', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `week('2022-07-21')`,
|
||||
sql: `STRFTIME('%W', ?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `datetime('2022-07-21')`,
|
||||
sql: `DATETIME(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `timestamp('2022-07-21')`,
|
||||
sql: `DATETIME(?)`,
|
||||
args: []any{"2022-07-21"},
|
||||
},
|
||||
{
|
||||
qry: `date_format('2022-07-21','%d')`,
|
||||
sql: `STRFTIME(?, ?)`,
|
||||
args: []any{"%d", "2022-07-21"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.qry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
ee, err := conv.Parse(c.qry)
|
||||
req.NoError(err)
|
||||
|
||||
sql, args, err := dialect.GOQU().Select(ee).ToSQL()
|
||||
req.NoError(err)
|
||||
|
||||
p := strings.Index(sql, SELECT)
|
||||
req.Zero(p)
|
||||
|
||||
sql = sql[p+len(SELECT):]
|
||||
|
||||
req.Equal(c.sql, sql)
|
||||
req.Equal(c.args, args)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
104
server/store/adapters/rdbms/drivers/sqlite/types.go
Normal file
104
server/store/adapters/rdbms/drivers/sqlite/types.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"github.com/spf13/cast"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
)
|
||||
|
||||
type (
|
||||
TypeDate struct{ *dal.TypeDate }
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
TypeTimestamp struct{ *dal.TypeTimestamp }
|
||||
)
|
||||
|
||||
func (*TypeDate) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
|
||||
func (t *TypeDate) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
parsed, err := cast.StringToDate(dec.String)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
return parsed.Format(drivers.DateLayout), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeDate) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
return dec.Time.Format(drivers.TimeLayout(false, t.Precision)), dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeTimestamp) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
|
||||
func (t *TypeTimestamp) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Timestamp", raw)
|
||||
}
|
||||
|
||||
if dec.Valid {
|
||||
val := dec.Time.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
return val, dec.Valid, nil
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
switch c := val.(type) {
|
||||
case string:
|
||||
parsed, err := cast.StringToDate(c)
|
||||
if err != nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
val = parsed.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
|
||||
case time.Time:
|
||||
val = c.Format(drivers.TimestampLayout(false, t.Precision))
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
187
server/store/adapters/rdbms/drivers/table.go
Normal file
187
server/store/adapters/rdbms/drivers/table.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
)
|
||||
|
||||
type (
|
||||
// TableCodec is an RDBMS representation of data.Model structure and its arguments
|
||||
TableCodec interface {
|
||||
Columns() []Column
|
||||
Ident() exp.IdentifierExpression
|
||||
MakeScanBuffer() []any
|
||||
Encode(r dal.ValueGetter) (_ []any, err error)
|
||||
Decode(buf []any, r dal.ValueSetter) (err error)
|
||||
AttributeExpression(string) (exp.Expression, error)
|
||||
AttributeExpressionQuoted(string) (exp.Expression, error)
|
||||
}
|
||||
|
||||
// GenericTableCodec is a generic implementation of TableCodec
|
||||
GenericTableCodec struct {
|
||||
// table identifier (name)
|
||||
ident exp.IdentifierExpression
|
||||
|
||||
// all columns we're selecting from when
|
||||
// we're selecting from all columns
|
||||
columns []Column
|
||||
|
||||
model *dal.Model
|
||||
dialect Dialect
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ TableCodec = &GenericTableCodec{}
|
||||
)
|
||||
|
||||
func NewTableCodec(m *dal.Model, d Dialect) *GenericTableCodec {
|
||||
gtc := &GenericTableCodec{
|
||||
dialect: d,
|
||||
model: m,
|
||||
ident: exp.NewIdentifierExpression("", m.Ident, ""),
|
||||
}
|
||||
|
||||
var (
|
||||
colIdent string
|
||||
attr *dal.Attribute
|
||||
done = make(map[string]bool)
|
||||
cols = make([]Column, 0, len(m.Attributes))
|
||||
)
|
||||
|
||||
for a := range m.Attributes {
|
||||
attr = m.Attributes[a]
|
||||
colIdent = attr.StoreIdent()
|
||||
|
||||
switch attr.Store.(type) {
|
||||
case *dal.CodecRecordValueSetJSON:
|
||||
if done[colIdent] {
|
||||
continue
|
||||
}
|
||||
|
||||
// when dealing with encoded types there is probably
|
||||
// a different column that can handle the encoded payload
|
||||
cols = append(cols, &SimpleJsonDocColumn{
|
||||
name: colIdent,
|
||||
attributes: collectStdRecordValueJSONColumns(colIdent, m.Attributes...),
|
||||
})
|
||||
default:
|
||||
cols = append(cols, NewSingleValueColumn(d, attr))
|
||||
}
|
||||
|
||||
done[colIdent] = true
|
||||
}
|
||||
|
||||
gtc.columns = cols
|
||||
|
||||
return gtc
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Ident() exp.IdentifierExpression {
|
||||
return t.ident
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Columns() []Column {
|
||||
return t.columns
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) MakeScanBuffer() []any {
|
||||
out := make([]any, len(t.columns))
|
||||
|
||||
for c := range t.columns {
|
||||
out[c] = t.columns[c].Type().MakeScanBuffer()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Encode(r dal.ValueGetter) (_ []any, err error) {
|
||||
enc := make([]any, len(t.columns))
|
||||
|
||||
for c := range t.columns {
|
||||
enc[c], err = t.columns[c].Encode(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) Decode(buf []any, r dal.ValueSetter) (err error) {
|
||||
if len(buf) != len(t.columns) {
|
||||
return fmt.Errorf("columns incompatilbe with scan buffer")
|
||||
}
|
||||
|
||||
for i, c := range t.columns {
|
||||
if err = c.Decode(buf[i], r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) AttributeExpressionQuoted(ident string) (exp.Expression, error) {
|
||||
return t.attributeExpression(true, ident)
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) AttributeExpression(ident string) (exp.Expression, error) {
|
||||
return t.attributeExpression(false, ident)
|
||||
}
|
||||
|
||||
func (t *GenericTableCodec) attributeExpression(quoted bool, ident string) (exp.Expression, error) {
|
||||
attr := t.model.Attributes.FindByIdent(ident)
|
||||
|
||||
if attr == nil {
|
||||
return nil, fmt.Errorf("unknown attribute %q", ident)
|
||||
}
|
||||
|
||||
switch s := attr.Store.(type) {
|
||||
case *dal.CodecAlias:
|
||||
return t.dialect.AttributeExpression(attr, t.model.Ident, s.Ident)
|
||||
|
||||
case *dal.CodecRecordValueSetJSON:
|
||||
idfExpr := exp.NewIdentifierExpression("", t.model.Ident, s.Ident)
|
||||
|
||||
var lit exp.Expression
|
||||
var err error
|
||||
if quoted {
|
||||
lit, err = t.dialect.JsonExtract(idfExpr, attr.Ident)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lit, nil
|
||||
} else {
|
||||
// using JSON to handle embedded values
|
||||
lit, err = t.dialect.JsonExtractUnquote(idfExpr, attr.Ident, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.dialect.AttributeCast(attr, lit)
|
||||
}
|
||||
}
|
||||
|
||||
return exp.NewLiteralExpression("?", exp.NewIdentifierExpression("", t.model.Ident, ident)), nil
|
||||
}
|
||||
|
||||
func collectStdRecordValueJSONColumns(ident string, aa ...*dal.Attribute) []*dal.Attribute {
|
||||
filtered := make([]*dal.Attribute, 0)
|
||||
for _, a := range aa {
|
||||
storeType, is := a.Store.(*dal.CodecRecordValueSetJSON)
|
||||
if !is {
|
||||
continue
|
||||
}
|
||||
|
||||
if ident != storeType.Ident {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
3
server/store/adapters/rdbms/drivers/tests/.env.example
Normal file
3
server/store/adapters/rdbms/drivers/tests/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
#DB_DSN=mysql://uod:pwd@tcp(localhost:3306)/dbname?collation=utf8mb4_general_ci
|
||||
#DB_DSN=postgres://uid:pwd@localhost:5432/dbame?sslmode=disable
|
||||
#DB_DSN=sqlite3://file::memory:?cache=shared&mode=memory
|
||||
1
server/store/adapters/rdbms/drivers/tests/.gitignore
vendored
Normal file
1
server/store/adapters/rdbms/drivers/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
17
server/store/adapters/rdbms/drivers/tests/connection_test.go
Normal file
17
server/store/adapters/rdbms/drivers/tests/connection_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectionPing(t *testing.T) {
|
||||
t.Logf("testing %s", conn.dsn)
|
||||
|
||||
toCtx, cfn := context.WithTimeout(ctx, time.Second)
|
||||
defer cfn()
|
||||
|
||||
require.NoError(t, conn.db.PingContext(toCtx))
|
||||
}
|
||||
68
server/store/adapters/rdbms/drivers/tests/dal_model_test.go
Normal file
68
server/store/adapters/rdbms/drivers/tests/dal_model_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestModelManagement(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
log = logger.Default()
|
||||
)
|
||||
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
if conn.isSQLite {
|
||||
t.Skip("sqlite does not support index management")
|
||||
}
|
||||
|
||||
const (
|
||||
dalConnID = 1
|
||||
dalTableName = "lil_dal_test"
|
||||
)
|
||||
|
||||
_, err := conn.db.Exec("DROP TABLE IF EXISTS " + dalTableName)
|
||||
req.NoError(err)
|
||||
|
||||
cw := dal.MakeConnection(
|
||||
dalConnID,
|
||||
conn.store.ToDalConn(),
|
||||
dal.ConnectionParams{},
|
||||
dal.ConnectionConfig{},
|
||||
)
|
||||
|
||||
svc, err := dal.New(log, true)
|
||||
req.NoError(err)
|
||||
req.NoError(svc.ReplaceConnection(ctx, cw, true))
|
||||
|
||||
_, err = svc.ReplaceModel(ctx, nil, &dal.Model{
|
||||
Ident: dalTableName,
|
||||
ConnectionID: dalConnID,
|
||||
Attributes: dal.AttributeSet{
|
||||
&dal.Attribute{
|
||||
Ident: "ID",
|
||||
PrimaryKey: true,
|
||||
Type: &dal.TypeID{},
|
||||
Store: &dal.CodecAlias{Ident: "id"},
|
||||
},
|
||||
|
||||
&dal.Attribute{
|
||||
Ident: "OwnerID",
|
||||
Sortable: true,
|
||||
Type: &dal.TypeText{},
|
||||
Store: &dal.CodecAlias{Ident: "rel_owner"},
|
||||
},
|
||||
},
|
||||
})
|
||||
req.NoError(err)
|
||||
|
||||
_, err = conn.db.Exec("DROP TABLE " + dalTableName)
|
||||
req.NoError(err)
|
||||
}
|
||||
169
server/store/adapters/rdbms/drivers/tests/dialect_test.go
Normal file
169
server/store/adapters/rdbms/drivers/tests/dialect_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONOp(t *testing.T) {
|
||||
var (
|
||||
tbl = ddl.Table{
|
||||
Ident: "test_json_path_test",
|
||||
Columns: []*ddl.Column{
|
||||
{Ident: "c", Type: &ddl.ColumnType{Name: "JSON"}},
|
||||
},
|
||||
Temporary: true,
|
||||
}
|
||||
|
||||
req = require.New(t)
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
count = func(req *require.Assertions, expr ...exp.Expression) int {
|
||||
var (
|
||||
out = struct {
|
||||
Count int `db:"count"`
|
||||
}{}
|
||||
)
|
||||
|
||||
query := conn.dialect.GOQU().
|
||||
Select(goqu.COUNT(goqu.Star()).As("count")).
|
||||
From(tbl.Ident).
|
||||
Where(expr...)
|
||||
|
||||
err := conn.store.QueryOne(ctx, query, &out)
|
||||
req.NoError(err)
|
||||
|
||||
return out.Count
|
||||
}
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
countDeepJSON = func(req *require.Assertions, val string) int {
|
||||
col := exp.NewIdentifierExpression("", tbl.Ident, "c")
|
||||
diJSON, err := conn.dialect.JsonExtractUnquote(col, "a", "b", "c")
|
||||
req.NoError(err)
|
||||
|
||||
return count(req, exp.NewLiteralExpression("?", diJSON).Eq(val))
|
||||
}
|
||||
|
||||
a2e = func(attr *dal.Attribute) exp.Expression {
|
||||
expr, err := conn.dialect.JsonExtract(
|
||||
exp.NewIdentifierExpression("", tbl.Ident, attr.StoreIdent()),
|
||||
attr.Ident,
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
return expr
|
||||
}
|
||||
|
||||
// test utility function
|
||||
// counts how many rows have a value in the json path
|
||||
countContains = func(req *require.Assertions, attr *dal.Attribute, val any) int {
|
||||
contains, err := conn.dialect.JsonArrayContains(
|
||||
exp.NewLiteralExpression("?", val),
|
||||
a2e(attr),
|
||||
)
|
||||
req.NoError(err)
|
||||
|
||||
return count(req, contains)
|
||||
}
|
||||
|
||||
// test utility function
|
||||
insert = func(vv ...string) {
|
||||
for _, val := range vv {
|
||||
insert := conn.dialect.GOQU().
|
||||
Insert(tbl.Ident).
|
||||
Cols("c").
|
||||
Vals(goqu.Vals{val})
|
||||
|
||||
req.NoError(conn.store.Exec(ctx, insert))
|
||||
}
|
||||
}
|
||||
|
||||
asJSON = func(val any) string {
|
||||
enc, err := json.Marshal(val)
|
||||
req.NoError(err)
|
||||
return string(enc)
|
||||
}
|
||||
|
||||
jsonStore = &dal.CodecRecordValueSetJSON{Ident: "c"}
|
||||
numAttr = &dal.Attribute{Ident: "n", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textAttr = &dal.Attribute{Ident: "s", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolAttr = &dal.Attribute{Ident: "b", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
|
||||
numCheck0Attr = &dal.Attribute{Ident: "nCheck0", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textCheck0Attr = &dal.Attribute{Ident: "sCheck0", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolCheck0Attr = &dal.Attribute{Ident: "bCheck0", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
|
||||
numCheck1Attr = &dal.Attribute{Ident: "nCheck1", Type: &dal.TypeNumber{}, Store: jsonStore}
|
||||
textCheck1Attr = &dal.Attribute{Ident: "sCheck1", Type: &dal.TypeText{}, Store: jsonStore}
|
||||
boolCheck1Attr = &dal.Attribute{Ident: "bCheck1", Type: &dal.TypeBoolean{}, Store: jsonStore}
|
||||
)
|
||||
|
||||
{
|
||||
// check if table exists, drop it and create a new, temporary one
|
||||
|
||||
dd := conn.store.DataDefiner
|
||||
|
||||
// utilize DDL and create a table with a json column
|
||||
_, err := dd.TableLookup(ctx, tbl.Ident)
|
||||
if errors.IsNotFound(err) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
err = dd.TableDrop(ctx, tbl.Ident)
|
||||
}
|
||||
|
||||
req.NoError(err)
|
||||
req.NoError(dd.TableCreate(ctx, &tbl))
|
||||
}
|
||||
|
||||
t.Run("deep ident json", func(t *testing.T) {
|
||||
req = require.New(t)
|
||||
req.NoError(conn.store.Exec(ctx, conn.dialect.GOQU().Truncate(tbl.Ident)))
|
||||
|
||||
insert(`{"a": {"b": {"c": "match"}}}`)
|
||||
|
||||
req.Equal(1, countDeepJSON(req, "match"))
|
||||
req.Equal(0, countDeepJSON(req, "nope"))
|
||||
})
|
||||
|
||||
t.Run("json array contains", func(t *testing.T) {
|
||||
req = require.New(t)
|
||||
req.NoError(conn.store.Exec(ctx, conn.dialect.GOQU().Truncate(tbl.Ident)))
|
||||
|
||||
insert(`{"n": [1,2,3], "b": [true], "s": ["foo", "bar"], "nCheck0": 0, "bCheck0": false, "sCheck0": "baz", "nCheck1": 1, "bCheck1": true, "sCheck1": "foo"}`)
|
||||
|
||||
t.Log("Validating contains check with numeric value")
|
||||
req.Equal(1, countContains(req, numAttr, asJSON(1)))
|
||||
req.Equal(0, countContains(req, numAttr, asJSON(0)))
|
||||
|
||||
t.Log("Validating contains check with string value")
|
||||
req.Equal(1, countContains(req, textAttr, asJSON(`foo`)))
|
||||
req.Equal(1, countContains(req, textAttr, asJSON(`bar`)))
|
||||
req.Equal(0, countContains(req, textAttr, asJSON(`baz`)))
|
||||
|
||||
t.Log("Validating contains check with boolean value")
|
||||
req.Equal(1, countContains(req, boolAttr, asJSON(true)))
|
||||
req.Equal(0, countContains(req, boolAttr, asJSON(false)))
|
||||
|
||||
t.Log("Validating contains check with numeric field")
|
||||
req.Equal(1, countContains(req, numAttr, a2e(numCheck1Attr)))
|
||||
req.Equal(0, countContains(req, numAttr, a2e(numCheck0Attr)))
|
||||
|
||||
t.Log("Validating contains check with string field")
|
||||
req.Equal(1, countContains(req, textAttr, a2e(textCheck1Attr)))
|
||||
req.Equal(1, countContains(req, textAttr, a2e(textCheck1Attr)))
|
||||
req.Equal(0, countContains(req, textAttr, a2e(textCheck0Attr)))
|
||||
|
||||
t.Log("Validating contains check with boolean field")
|
||||
req.Equal(1, countContains(req, boolAttr, a2e(boolCheck1Attr)))
|
||||
req.Equal(0, countContains(req, boolAttr, a2e(boolCheck0Attr)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexManagement(t *testing.T) {
|
||||
t.Skip("not implemented")
|
||||
|
||||
//if conn.isSQLite {
|
||||
// t.Skip("sqlite does not support index management")
|
||||
//}
|
||||
//
|
||||
//var (
|
||||
// req = require.New(t)
|
||||
// dd = conn.store.DataDefiner
|
||||
// ii, err = dd.IndexLookup(ctx, ddl.PRIMARY_KEY, "users")
|
||||
//)
|
||||
//
|
||||
//req.NoError(err)
|
||||
//req.NotNil(ii)
|
||||
}
|
||||
128
server/store/adapters/rdbms/drivers/tests/main_test.go
Normal file
128
server/store/adapters/rdbms/drivers/tests/main_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/cli"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/mysql"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/postgres"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
"github.com/cortezaproject/corteza/server/tests/helpers"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
// test utility struct for testing various RDBMS implementations
|
||||
connectionInfo struct {
|
||||
dsn string
|
||||
|
||||
config *rdbms.ConnConfig
|
||||
store *rdbms.Store
|
||||
db *sqlx.DB
|
||||
dialect drivers.Dialect
|
||||
|
||||
// used for creating tables
|
||||
// @todo remove when we have proper DDL abstraction
|
||||
isSQLite bool
|
||||
isMySQL bool
|
||||
isPostgres bool
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
DB_DSN_DEFAULT = "sqlite3://file::memory:?cache=shared&mode=memory"
|
||||
)
|
||||
|
||||
var (
|
||||
// all connections, preloaded
|
||||
conn *connectionInfo
|
||||
|
||||
log = logger.MakeDebugLogger()
|
||||
|
||||
// all tests should use this context to pass
|
||||
// into DB calls to ensure commands are logged
|
||||
ctx = logger.ContextWithValue(context.Background(), log)
|
||||
)
|
||||
|
||||
func init() {
|
||||
helpers.RecursiveDotEnvLoad()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cli.HandleError(connect())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func connect() (err error) {
|
||||
var (
|
||||
is bool
|
||||
|
||||
s store.Storer
|
||||
)
|
||||
|
||||
conn = &connectionInfo{}
|
||||
|
||||
if conn.dsn, is = os.LookupEnv("DB_DSN"); !is {
|
||||
conn.dsn = DB_DSN_DEFAULT
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(conn.dsn, sqlite.SCHEMA):
|
||||
conn.config, err = sqlite.NewConfig(conn.dsn)
|
||||
conn.dialect = sqlite.Dialect()
|
||||
conn.isSQLite = true
|
||||
case strings.HasPrefix(conn.dsn, postgres.SCHEMA):
|
||||
conn.config, err = postgres.NewConfig(conn.dsn)
|
||||
conn.dialect = postgres.Dialect()
|
||||
conn.isPostgres = true
|
||||
case strings.HasPrefix(conn.dsn, mysql.SCHEMA):
|
||||
conn.config, err = mysql.NewConfig(conn.dsn)
|
||||
conn.dialect = mysql.Dialect()
|
||||
conn.isMySQL = true
|
||||
default:
|
||||
return fmt.Errorf("unsupported DB (dns: %q)", conn.dsn)
|
||||
}
|
||||
|
||||
// check each dsn by prefix and use newConnConfig from
|
||||
// appropriate driver
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("can not create config from %s: %w", conn.dsn, err)
|
||||
}
|
||||
|
||||
// need to connect in under 1 second
|
||||
toCtx, cfn := context.WithTimeout(ctx, time.Second*5)
|
||||
defer cfn()
|
||||
|
||||
s, err = store.Connect(toCtx, log, conn.dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can not connect to %q: %w", conn.config.DriverName, err)
|
||||
}
|
||||
|
||||
if conn.store, is = s.(*rdbms.Store); !is {
|
||||
return fmt.Errorf("can not extract *store.RDBMS from store")
|
||||
}
|
||||
|
||||
if conn.db, is = conn.store.DB.(*sqlx.DB); !is {
|
||||
return fmt.Errorf("can not extract *sqlx.DB from store")
|
||||
}
|
||||
|
||||
log.Debug("connected to " + conn.dsn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exec(q string) error {
|
||||
_, err := conn.db.ExecContext(ctx, q)
|
||||
return err
|
||||
|
||||
}
|
||||
333
server/store/adapters/rdbms/drivers/types.go
Normal file
333
server/store/adapters/rdbms/drivers/types.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
Type interface {
|
||||
MakeScanBuffer() any
|
||||
Decode(any) (any, bool, error)
|
||||
Encode(any) (driver.Value, error)
|
||||
}
|
||||
|
||||
// @todo makes sense to rethink this strategy
|
||||
// we do not need to have 1:1 type pairings with the pkg/dal
|
||||
// it makes sense to define a few most common ones here (in the rdbms/drivers)
|
||||
// and introduce per/driver exceptions to handle implementation specific things
|
||||
// like MySQLs lack of BOOLEAN that is replaced with TINYINT(1)
|
||||
|
||||
TypeID struct{ *dal.TypeID }
|
||||
TypeRef struct{ *dal.TypeRef }
|
||||
TypeTimestamp struct{ *dal.TypeTimestamp }
|
||||
TypeTime struct{ *dal.TypeTime }
|
||||
TypeDate struct{ *dal.TypeDate }
|
||||
TypeNumber struct{ *dal.TypeNumber }
|
||||
TypeText struct{ *dal.TypeText }
|
||||
TypeBoolean struct{ *dal.TypeBoolean }
|
||||
TypeEnum struct{ *dal.TypeEnum }
|
||||
TypeGeometry struct{ *dal.TypeGeometry }
|
||||
TypeJSON struct{ *dal.TypeJSON }
|
||||
TypeBlob struct{ *dal.TypeBlob }
|
||||
TypeUUID struct{ *dal.TypeUUID }
|
||||
)
|
||||
|
||||
// TypeWrap wraps type from data package
|
||||
func TypeWrap(dt dal.Type) Type {
|
||||
switch c := dt.(type) {
|
||||
case *dal.TypeID:
|
||||
return &TypeID{c}
|
||||
case *dal.TypeRef:
|
||||
return &TypeRef{c}
|
||||
case *dal.TypeTimestamp:
|
||||
return &TypeTimestamp{c}
|
||||
case *dal.TypeTime:
|
||||
return &TypeTime{c}
|
||||
case *dal.TypeDate:
|
||||
return &TypeDate{c}
|
||||
case *dal.TypeNumber:
|
||||
return &TypeNumber{c}
|
||||
case *dal.TypeText:
|
||||
return &TypeText{c}
|
||||
case *dal.TypeBoolean:
|
||||
return &TypeBoolean{c}
|
||||
case *dal.TypeEnum:
|
||||
return &TypeEnum{c}
|
||||
case *dal.TypeGeometry:
|
||||
return &TypeGeometry{c}
|
||||
case *dal.TypeJSON:
|
||||
return &TypeJSON{c}
|
||||
case *dal.TypeBlob:
|
||||
return &TypeBlob{c}
|
||||
case *dal.TypeUUID:
|
||||
return &TypeUUID{c}
|
||||
}
|
||||
|
||||
if dt == nil {
|
||||
panic(fmt.Sprintf("attribute type not set (nil)"))
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("type implementation missing: %s", dt.Type()))
|
||||
}
|
||||
|
||||
func (*TypeID) MakeScanBuffer() any { return new(ID) }
|
||||
func (*TypeRef) MakeScanBuffer() any { return new(ID) }
|
||||
func (*TypeTimestamp) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
func (*TypeTime) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeDate) MakeScanBuffer() any { return new(sql.NullTime) }
|
||||
func (*TypeNumber) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeText) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeBoolean) MakeScanBuffer() any { return new(sql.NullBool) }
|
||||
func (*TypeEnum) MakeScanBuffer() any { return new(sql.NullString) }
|
||||
func (*TypeGeometry) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
func (*TypeJSON) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
func (*TypeBlob) MakeScanBuffer() any { return new(sql.RawBytes) }
|
||||
|
||||
func (t *TypeID) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*ID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for ID", raw)
|
||||
}
|
||||
|
||||
return dec.ID, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeID) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeRef) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*ID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Ref", raw)
|
||||
}
|
||||
|
||||
return dec.ID, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeRef) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Timestamp", raw)
|
||||
}
|
||||
|
||||
if dec.Valid {
|
||||
return dec.Time.UTC().Format(TimestampLayout(t.Timezone, t.Precision)), dec.Valid, nil
|
||||
}
|
||||
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (t *TypeTimestamp) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) || val == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeTime) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Time", raw)
|
||||
}
|
||||
|
||||
if !dec.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(TimeLayout(t.Timezone, t.Precision), dec.String)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
// @todo should we gracefully handle other combinations of time&precision?
|
||||
// maybe with Strict flag?
|
||||
|
||||
return parsed.Format(TimeLayout(t.Timezone, t.Precision)), true, nil
|
||||
}
|
||||
|
||||
func (t *TypeTime) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeDate) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullTime)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Date", raw)
|
||||
}
|
||||
|
||||
return dec.Time.Format(DateLayout), dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeDate) Encode(val any) (driver.Value, error) {
|
||||
if reflect2.IsNil(val) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return cast.ToTimeE(val)
|
||||
}
|
||||
|
||||
func (t *TypeNumber) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Number", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeNumber) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeText) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Text", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeText) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeBoolean) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullBool)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Boolean", raw)
|
||||
}
|
||||
|
||||
return dec.Bool, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeBoolean) Encode(val any) (driver.Value, error) {
|
||||
if val == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return cast.ToBool(val), nil
|
||||
}
|
||||
|
||||
func (t *TypeEnum) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*sql.NullString)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Enum", raw)
|
||||
}
|
||||
|
||||
return dec.String, dec.Valid, nil
|
||||
}
|
||||
|
||||
func (t *TypeEnum) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeGeometry) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Geometry", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeGeometry) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (t *TypeJSON) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for JSON", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeJSON) Encode(val any) (driver.Value, error) {
|
||||
switch c := val.(type) {
|
||||
case driver.Valuer:
|
||||
// does the value type know how to encode itself for the DB?
|
||||
return c.Value()
|
||||
|
||||
// These types are native to driver.Value
|
||||
case int64, float64, bool, []byte, string, time.Time:
|
||||
return c, nil
|
||||
|
||||
case json.Marshaler:
|
||||
// does the value type know how to encode itself as JSON?
|
||||
return c.MarshalJSON()
|
||||
|
||||
default:
|
||||
// Last resort - just encode with JSON pkg
|
||||
return json.Marshal(val)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TypeBlob) Decode(raw any) (any, bool, error) {
|
||||
bb, is := raw.(*sql.RawBytes)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for Blob", raw)
|
||||
}
|
||||
|
||||
return []byte(*bb), bb != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeBlob) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*TypeUUID) MakeScanBuffer() any { return new(uuid.UUID) }
|
||||
|
||||
func (t *TypeUUID) Decode(raw any) (any, bool, error) {
|
||||
dec, is := raw.(*uuid.UUID)
|
||||
if !is {
|
||||
return nil, false, fmt.Errorf("unexpected raw type %T for UUID", raw)
|
||||
}
|
||||
|
||||
return *dec, dec != nil, nil
|
||||
}
|
||||
|
||||
func (t *TypeUUID) Encode(val any) (driver.Value, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
const (
|
||||
DateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
func TimestampLayout(tz bool, precision int) string {
|
||||
return DateLayout + "T" + TimeLayout(tz, precision)
|
||||
}
|
||||
|
||||
func TimeLayout(tz bool, precision int) string {
|
||||
var layout = "15:04:05"
|
||||
if precision > 0 {
|
||||
layout += "." + strings.Repeat("9", precision)
|
||||
}
|
||||
|
||||
if tz {
|
||||
layout += "Z07:00"
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
69
server/store/adapters/rdbms/drivers/types_test.go
Normal file
69
server/store/adapters/rdbms/drivers/types_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTypeDecoders(t *testing.T) {
|
||||
var (
|
||||
cc = []struct {
|
||||
name string
|
||||
inp any
|
||||
typ Type
|
||||
err error
|
||||
ok bool
|
||||
dec any
|
||||
}{
|
||||
{
|
||||
name: "time -tz -prec",
|
||||
inp: &sql.NullString{String: "06:06:06", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{}),
|
||||
ok: true,
|
||||
dec: "06:06:06",
|
||||
},
|
||||
{
|
||||
name: "time -tz +prec",
|
||||
inp: &sql.NullString{String: "06:06:06.321", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{Precision: 3}),
|
||||
ok: true,
|
||||
dec: "06:06:06.321",
|
||||
},
|
||||
{
|
||||
name: "time +tz +prec",
|
||||
inp: &sql.NullString{String: "06:06:06.321+04:00", Valid: true},
|
||||
typ: TypeWrap(&dal.TypeTime{Precision: 3, Timezone: true}),
|
||||
ok: true,
|
||||
dec: "06:06:06.321+04:00",
|
||||
},
|
||||
//{
|
||||
// name: "time ?tz ?prec",
|
||||
// inp: &sql.NullString{String: "06:06:06", Valid: true},
|
||||
// typ: TypeWrap(&data.TypeTime{Precision: 3, Timezone: true}),
|
||||
// ok: true,
|
||||
// dec: "06:06:06.321+04:00",
|
||||
//},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
dec, ok, err := c.typ.Decode(c.inp)
|
||||
if c.err == nil {
|
||||
req.NoError(err)
|
||||
} else {
|
||||
req.ErrorIs(err, c.err)
|
||||
}
|
||||
|
||||
req.Equal(c.ok, ok)
|
||||
req.EqualValues(c.dec, dec)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user