MSSQL: Minor refactor (#113976)
* Moving things around
* Copy parseURL function to where it's used
* Update test
* Remove experimental-strip-types
* Revert "Remove experimental-strip-types"
This reverts commit 70fbc1c0cd.
* Trigger build
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/azure"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/azuread"
|
||||
)
|
||||
|
||||
func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) {
|
||||
var connector *mssql.Connector
|
||||
var err error
|
||||
if driverName == "azuresql" {
|
||||
connector, err = azuread.NewConnector(cnnstr)
|
||||
} else {
|
||||
connector, err = mssql.NewConnector(cnnstr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error("mssql connector creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql connector creation failed")
|
||||
}
|
||||
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
|
||||
if proxyClient.SecureSocksProxyEnabled() {
|
||||
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
URL, err := utils.ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// update the mssql dialer with the proxy dialer
|
||||
connector.Dialer = (mssqlDialer)
|
||||
}
|
||||
|
||||
config := DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: rowLimit,
|
||||
}
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
|
||||
db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns)
|
||||
db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
const (
|
||||
azureAuthentication = "Azure AD Authentication"
|
||||
windowsAuthentication = "Windows Authentication"
|
||||
sqlServerAuthentication = "SQL Server Authentication"
|
||||
kerberosRaw = "Windows AD: Username + password"
|
||||
kerberosKeytab = "Windows AD: Keytab"
|
||||
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
|
||||
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
|
||||
)
|
||||
|
||||
func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
|
||||
const dfltPort = "0"
|
||||
var addr util.NetworkAddress
|
||||
if dsInfo.URL != "" {
|
||||
u, err := utils.ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
addr = util.NetworkAddress{
|
||||
Host: "localhost",
|
||||
Port: dfltPort,
|
||||
}
|
||||
}
|
||||
|
||||
args := []any{
|
||||
"url", dsInfo.URL, "host", addr.Host,
|
||||
}
|
||||
if addr.Port != "0" {
|
||||
args = append(args, "port", addr.Port)
|
||||
}
|
||||
logger.Debug("Generating connection string", args...)
|
||||
|
||||
encrypt := dsInfo.JsonData.Encrypt
|
||||
tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify
|
||||
hostNameInCertificate := dsInfo.JsonData.Servername
|
||||
certificate := dsInfo.JsonData.RootCertFile
|
||||
connStr := fmt.Sprintf("server=%s;database=%s;",
|
||||
addr.Host,
|
||||
dsInfo.Database,
|
||||
)
|
||||
|
||||
switch dsInfo.JsonData.AuthenticationType {
|
||||
case azureAuthentication:
|
||||
azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
connStr += azureCredentialDSNFragment
|
||||
case windowsAuthentication:
|
||||
// No user id or password. We're using windows single sign on.
|
||||
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
|
||||
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
|
||||
default:
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
|
||||
}
|
||||
|
||||
// Port number 0 means to determine the port automatically, so we can let the driver choose
|
||||
if addr.Port != "0" {
|
||||
connStr += fmt.Sprintf("port=%s;", addr.Port)
|
||||
}
|
||||
switch encrypt {
|
||||
case "true":
|
||||
connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify)
|
||||
if hostNameInCertificate != "" {
|
||||
connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate)
|
||||
}
|
||||
|
||||
if certificate != "" {
|
||||
connStr += fmt.Sprintf("certificate=%s;", certificate)
|
||||
}
|
||||
case "disable":
|
||||
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
|
||||
}
|
||||
|
||||
if dsInfo.JsonData.ConnectionTimeout != 0 {
|
||||
connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout)
|
||||
}
|
||||
|
||||
return connStr, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
)
|
||||
|
||||
const rsIdentifier = `([_a-zA-Z0-9]+)`
|
||||
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
|
||||
|
||||
type msSQLMacroEngine struct {
|
||||
*SQLMacroEngineBase
|
||||
}
|
||||
|
||||
func newMssqlMacroEngine() SQLMacroEngine {
|
||||
return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()}
|
||||
}
|
||||
|
||||
func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange,
|
||||
sql string) (string, error) {
|
||||
// TODO: Return any error
|
||||
rExp, _ := regexp.Compile(sExpr)
|
||||
var macroError error
|
||||
|
||||
sql = m.ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string {
|
||||
args := strings.Split(groups[2], ",")
|
||||
for i, arg := range args {
|
||||
args[i] = strings.Trim(arg, " ")
|
||||
}
|
||||
res, err := m.evaluateMacro(timeRange, query, groups[1], args)
|
||||
if err != nil && macroError == nil {
|
||||
macroError = err
|
||||
return "macro_error()"
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
if macroError != nil {
|
||||
return "", macroError
|
||||
}
|
||||
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *backend.DataQuery, name string, args []string) (string, error) {
|
||||
switch name {
|
||||
case "__time":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s AS time", args[0]), nil
|
||||
case "__timeEpoch":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("DATEDIFF(second, '1970-01-01', %s) AS time", args[0]), nil
|
||||
case "__timeFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], timeRange.From.UTC().Format(time.RFC3339), timeRange.To.UTC().Format(time.RFC3339)), nil
|
||||
case "__timeFrom":
|
||||
return fmt.Sprintf("'%s'", timeRange.From.UTC().Format(time.RFC3339)), nil
|
||||
case "__timeTo":
|
||||
return fmt.Sprintf("'%s'", timeRange.To.UTC().Format(time.RFC3339)), nil
|
||||
case "__timeGroup":
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("macro %v needs time column and interval", name)
|
||||
}
|
||||
interval, err := gtime.ParseInterval(strings.Trim(args[1], `'"`))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("FLOOR(DATEDIFF(second, '1970-01-01', %s)/%.0f)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil
|
||||
case "__timeGroupAlias":
|
||||
tg, err := m.evaluateMacro(timeRange, query, "__timeGroup", args)
|
||||
if err == nil {
|
||||
return tg + " AS [time]", nil
|
||||
}
|
||||
return "", err
|
||||
case "__unixEpochFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().Unix(), args[0], timeRange.To.UTC().Unix()), nil
|
||||
case "__unixEpochNanoFilter":
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().UnixNano(), args[0], timeRange.To.UTC().UnixNano()), nil
|
||||
case "__unixEpochNanoFrom":
|
||||
return fmt.Sprintf("%d", timeRange.From.UTC().UnixNano()), nil
|
||||
case "__unixEpochNanoTo":
|
||||
return fmt.Sprintf("%d", timeRange.To.UTC().UnixNano()), nil
|
||||
case "__unixEpochGroup":
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
|
||||
}
|
||||
interval, err := gtime.ParseInterval(strings.Trim(args[1], `'`))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("FLOOR(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil
|
||||
case "__unixEpochGroupAlias":
|
||||
tg, err := m.evaluateMacro(timeRange, query, "__unixEpochGroup", args)
|
||||
if err == nil {
|
||||
return tg + " AS [time]", nil
|
||||
}
|
||||
return "", err
|
||||
default:
|
||||
return "", fmt.Errorf("unknown macro %q", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMacroEngine(t *testing.T) {
|
||||
engine := &msSQLMacroEngine{}
|
||||
query := &backend.DataQuery{
|
||||
JSON: []byte("{}"),
|
||||
}
|
||||
|
||||
dfltTimeRange := backend.TimeRange{}
|
||||
|
||||
t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) {
|
||||
from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC)
|
||||
to := from.Add(5 * time.Minute)
|
||||
timeRange := backend.TimeRange{From: from, To: to}
|
||||
|
||||
t.Run("interpolate __time function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, dfltTimeRange, "select $__time(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "select time_column AS time", sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeEpoch function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, dfltTimeRange, "select $__timeEpoch(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "select DATEDIFF(second, '1970-01-01', time_column) AS time", sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeEpoch function wrapped in aggregation", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, dfltTimeRange, "select min($__timeEpoch(time_column))")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "select min(DATEDIFF(second, '1970-01-01', time_column) AS time)", sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeFrom function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "select '2018-04-12T18:00:00Z'", sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeTo function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "select '2018-04-12T18:05:00Z'", sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeGroup function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
|
||||
require.Nil(t, err)
|
||||
sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql)
|
||||
require.Equal(t, sql+" AS [time]", sql2)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeGroup function with spaces around arguments", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')")
|
||||
require.Nil(t, err)
|
||||
sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql)
|
||||
require.Equal(t, sql+" AS [time]", sql2)
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeGroup function with fill (value = NULL)", func(t *testing.T) {
|
||||
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)")
|
||||
require.Nil(t, err)
|
||||
queryJson, err := query.JSON.MarshalJSON()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"null"}`, string(queryJson))
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeGroup function with fill (value = previous)", func(t *testing.T) {
|
||||
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', previous)")
|
||||
require.Nil(t, err)
|
||||
queryJson, err := query.JSON.MarshalJSON()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"previous"}`, string(queryJson))
|
||||
})
|
||||
|
||||
t.Run("interpolate __timeGroup function with fill (value = float)", func(t *testing.T) {
|
||||
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)")
|
||||
require.Nil(t, err)
|
||||
queryJson, err := query.JSON.MarshalJSON()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"value","fillValue":1.5}`, string(queryJson))
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
|
||||
})
|
||||
t.Run("interpolate __unixEpochNanoFrom function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select %d", from.UnixNano()), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochNanoTo function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select %d", to.UnixNano()), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochGroup function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
|
||||
require.Nil(t, err)
|
||||
sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "SELECT FLOOR(time_column/300)*300", sql)
|
||||
require.Equal(t, sql+" AS [time]", sql2)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func(t *testing.T) {
|
||||
from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC)
|
||||
to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC)
|
||||
timeRange := backend.TimeRange{
|
||||
From: from,
|
||||
To: to,
|
||||
}
|
||||
|
||||
t.Run("interpolate __timeFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func(t *testing.T) {
|
||||
from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC)
|
||||
to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC)
|
||||
timeRange := backend.TimeRange{
|
||||
From: from,
|
||||
To: to,
|
||||
}
|
||||
|
||||
t.Run("interpolate __timeFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
|
||||
})
|
||||
|
||||
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
|
||||
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
|
||||
})
|
||||
|
||||
t.Run("should return unmodified sql if there are no macros present", func(t *testing.T) {
|
||||
sqls := []string{
|
||||
"select * from table",
|
||||
"select count(val) from table",
|
||||
"select col1, col2,col3, col4 from table where col1 = 'val1' and col2 = 'val2' order by col1 asc",
|
||||
}
|
||||
|
||||
for _, sql := range sqls {
|
||||
actual, err := engine.Interpolate(query, timeRange, sql)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, sql, actual)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestMacroEngineConcurrency(t *testing.T) {
|
||||
engine := newMssqlMacroEngine()
|
||||
query1 := backend.DataQuery{
|
||||
JSON: []byte{},
|
||||
}
|
||||
query2 := backend.DataQuery{
|
||||
JSON: []byte{},
|
||||
}
|
||||
from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC)
|
||||
to := from.Add(5 * time.Minute)
|
||||
timeRange := backend.TimeRange{
|
||||
From: from,
|
||||
To: to,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func(query backend.DataQuery) {
|
||||
defer wg.Done()
|
||||
_, err := engine.Interpolate(&query, timeRange, "SELECT $__timeGroup(time_column,'5m')")
|
||||
require.NoError(t, err)
|
||||
}(query1)
|
||||
|
||||
go func(query backend.DataQuery) {
|
||||
_, err := engine.Interpolate(&query, timeRange, "SELECT $__timeGroup(time_column,'5m')")
|
||||
require.NoError(t, err)
|
||||
defer wg.Done()
|
||||
}(query2)
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type HostTransportDialer struct {
|
||||
Dialer proxy.ContextDialer
|
||||
Host string
|
||||
}
|
||||
|
||||
func (m HostTransportDialer) HostName() string {
|
||||
return m.Host
|
||||
}
|
||||
|
||||
func (m HostTransportDialer) DialContext(ctx context.Context, network string, addr string) (conn net.Conn, err error) {
|
||||
return m.Dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// // we wrap the proxy.Dialer to become dialer that the mssql module accepts
|
||||
func newMSSQLProxyDialer(hostName string, dialer proxy.Dialer) (mssql.Dialer, error) {
|
||||
contextDialer, ok := dialer.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil, errors.New("unable to cast socks proxy dialer to context proxy dialer")
|
||||
}
|
||||
|
||||
return &HostTransportDialer{contextDialer, hostName}, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type testDialer struct {
|
||||
Host string
|
||||
}
|
||||
|
||||
func (d *testDialer) Dial(network, addr string) (c net.Conn, err error) {
|
||||
return nil, fmt.Errorf("test-dialer: Dial is not functional")
|
||||
}
|
||||
|
||||
func (d *testDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
hostWithPort := d.HostName() + ":1433"
|
||||
if address != hostWithPort {
|
||||
return nil, fmt.Errorf("test-dialer: address does not match hostname")
|
||||
}
|
||||
return nil, fmt.Errorf("test-dialer: DialContext is not functional")
|
||||
}
|
||||
|
||||
func (d *testDialer) HostName() string {
|
||||
return d.Host
|
||||
}
|
||||
|
||||
var _ proxy.Dialer = (&testDialer{})
|
||||
|
||||
func TestMSSQLProxyDriver(t *testing.T) {
|
||||
t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) {
|
||||
host := "127.0.0.1"
|
||||
cnnstr := fmt.Sprintf("server=%s;port=1433;user id=sa;password=yourStrong(!)Password;database=db", host)
|
||||
connector, err := mssql.NewConnector(cnnstr)
|
||||
require.NoError(t, err)
|
||||
|
||||
td := testDialer{
|
||||
Host: host,
|
||||
}
|
||||
dialer, err := newMSSQLProxyDialer("%s", &td)
|
||||
require.NoError(t, err)
|
||||
|
||||
connector.Dialer = (dialer)
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
err = db.Ping()
|
||||
|
||||
require.Contains(t, err.Error(), "test-dialer: DialContext is not functional")
|
||||
})
|
||||
|
||||
t.Run("Connector should use hostname rather than attempting to resolve IP", func(t *testing.T) {
|
||||
host := "www.grafana.com"
|
||||
cnnstr := fmt.Sprintf("server=%s;port=1433;user id=sa;password=yourStrong(!)Password;database=db", host)
|
||||
connector, err := mssql.NewConnector(cnnstr)
|
||||
require.NoError(t, err)
|
||||
|
||||
td := testDialer{
|
||||
Host: host,
|
||||
}
|
||||
dialer, err := newMSSQLProxyDialer(host, &td)
|
||||
require.NoError(t, err)
|
||||
|
||||
connector.Dialer = (dialer)
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
err = db.Ping()
|
||||
|
||||
require.Contains(t, err.Error(), "test-dialer: DialContext is not functional")
|
||||
})
|
||||
}
|
||||
@@ -14,11 +14,16 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
)
|
||||
|
||||
// MetaKeyExecutedQueryString is the key where the executed query should get stored
|
||||
@@ -88,6 +93,10 @@ type DataSourceHandler struct {
|
||||
dsInfo DataSourceInfo
|
||||
rowLimit int64
|
||||
userError string
|
||||
azureSettings *azsettings.AzureSettings
|
||||
azureCredentials azcredentials.AzureCredentials
|
||||
kerberosAuth kerberos.KerberosAuth
|
||||
driverName string
|
||||
}
|
||||
|
||||
type QueryJson struct {
|
||||
@@ -113,16 +122,38 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er
|
||||
return e.queryResultTransformer.TransformQueryError(logger, err)
|
||||
}
|
||||
|
||||
func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer,
|
||||
macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) {
|
||||
func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanceSettings, userFacingDefaultError string, config DataPluginConfiguration,
|
||||
log log.Logger, azureSettings *azsettings.AzureSettings) (*DataSourceHandler, error) {
|
||||
queryResultTransformer := utils.MSSQLQueryResultTransformer{
|
||||
UserError: userFacingDefaultError,
|
||||
}
|
||||
driverName := "mssql"
|
||||
if config.DSInfo.JsonData.AuthenticationType == azureAuthentication {
|
||||
driverName = "azuresql"
|
||||
}
|
||||
|
||||
azureCredentials, err := utils.GetAzureCredentials(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading azure credentials")
|
||||
}
|
||||
|
||||
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
|
||||
}
|
||||
|
||||
queryDataHandler := DataSourceHandler{
|
||||
queryResultTransformer: queryResultTransformer,
|
||||
macroEngine: macroEngine,
|
||||
queryResultTransformer: &queryResultTransformer,
|
||||
macroEngine: newMssqlMacroEngine(),
|
||||
timeColumnNames: []string{"time"},
|
||||
log: log,
|
||||
dsInfo: config.DSInfo,
|
||||
rowLimit: config.RowLimit,
|
||||
userError: userFacingDefaultError,
|
||||
azureSettings: azureSettings,
|
||||
azureCredentials: azureCredentials,
|
||||
kerberosAuth: kerberosAuth,
|
||||
driverName: driverName,
|
||||
}
|
||||
|
||||
if len(config.TimeColumnNames) > 0 {
|
||||
@@ -133,7 +164,19 @@ func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataP
|
||||
queryDataHandler.metricColumnTypes = config.MetricColumnTypes
|
||||
}
|
||||
|
||||
cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryDataHandler.db = db
|
||||
|
||||
return &queryDataHandler, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng/util"
|
||||
)
|
||||
|
||||
@@ -436,3 +439,267 @@ func (t *testQueryResultTransformer) TransformQueryError(_ log.Logger, err error
|
||||
func (t *testQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return nil
|
||||
}
|
||||
|
||||
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
|
||||
content, err := json.Marshal(lookups)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "lookup*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(content); err != nil {
|
||||
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
func TestGenerateConnectionString(t *testing.T) {
|
||||
kerberosLookup := []kerberos.KerberosLookup{
|
||||
{
|
||||
Address: "example.host",
|
||||
DBName: "testDB",
|
||||
User: "testUser",
|
||||
CredentialCacheFilename: "/tmp/cache",
|
||||
},
|
||||
}
|
||||
tmpFile := genTempCacheFile(t, kerberosLookup)
|
||||
defer func() {
|
||||
err := os.Remove(tmpFile)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
kerberosCfg kerberos.KerberosAuth
|
||||
dataSource DataSourceInfo
|
||||
expConnStr string
|
||||
}{
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCache: "/tmp/krb5cc_1000",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache File path",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCacheLookupFile: tmpFile,
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "example.host",
|
||||
Database: "testDB",
|
||||
User: "testUser",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache file",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Keytab",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
KeytabFilePath: "/foo/bar.keytab",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Keytab",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Username and Password",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
|
||||
},
|
||||
{
|
||||
desc: "Use non-default UDP connection limit",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 0,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;",
|
||||
},
|
||||
|
||||
{
|
||||
desc: "From URL w/ port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost:1001",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;",
|
||||
},
|
||||
// When no port is specified, the driver should be allowed to choose
|
||||
{
|
||||
desc: "From URL w/o port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
// Port 0 should be equivalent to not specifying a port, i.e. let the driver choose
|
||||
{
|
||||
desc: "From URL w port 0",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost:0",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "Defaults",
|
||||
dataSource: DataSourceInfo{
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
}
|
||||
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expConnStr, connStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user