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:
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql"
|
||||
)
|
||||
|
||||
var logger = log.New("datasource")
|
||||
@@ -71,7 +70,7 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
|
||||
var err error
|
||||
switch strings.ToLower(typeName) {
|
||||
case "mssql":
|
||||
u, err = mssql.ParseURL(urlStr, logger)
|
||||
u, err = parseURL(urlStr, logger)
|
||||
default:
|
||||
logger.Debug("Applying default URL parsing for this data source type", "type", typeName, "url", urlStr)
|
||||
|
||||
@@ -90,3 +89,28 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an URL string into a URL object.
|
||||
func parseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -886,7 +886,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) {
|
||||
description: "Invalid ODBC URL",
|
||||
url: `localhost\instance::1433`,
|
||||
err: datasource.URLValidationError{
|
||||
Err: errors.New(`unrecognized MSSQL URL format: "localhost\\instance::1433"`),
|
||||
Err: errors.New(`unrecognized URL format: "localhost\\instance::1433"`),
|
||||
URL: `localhost\instance::1433`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
)
|
||||
|
||||
func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
|
||||
connStr := ""
|
||||
switch c := azureCredentials.(type) {
|
||||
case *azcredentials.AzureManagedIdentityCredentials:
|
||||
if azureManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
|
||||
}
|
||||
connStr += fmt.Sprintf("fedauth=%s;",
|
||||
"ActiveDirectoryManagedIdentity")
|
||||
case *azcredentials.AzureClientSecretCredentials:
|
||||
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
|
||||
c.ClientId,
|
||||
c.TenantId,
|
||||
c.ClientSecret,
|
||||
"ActiveDirectoryApplication",
|
||||
)
|
||||
case *azcredentials.AzureEntraPasswordCredentials:
|
||||
if azureEntraPasswordCredentialsEnabled {
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
|
||||
c.UserId,
|
||||
c.Password,
|
||||
c.ClientId,
|
||||
"ActiveDirectoryPassword",
|
||||
)
|
||||
} else {
|
||||
return "", fmt.Errorf("azure entra password authentication is not enabled")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported azure authentication type")
|
||||
}
|
||||
return connStr, nil
|
||||
}
|
||||
+10
-358
@@ -2,32 +2,18 @@ package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/azuread"
|
||||
_ "github.com/microsoft/go-mssqldb/integratedauth/krb5"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -35,16 +21,6 @@ type Service struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
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 ProvideService(cfg *setting.Cfg) *Service {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.mssql")
|
||||
return &Service{
|
||||
@@ -70,72 +46,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
return dsHandler.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
func newMSSQL(ctx context.Context, driverName string, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, 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, 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, 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, nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
URL, err := ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// update the mssql dialer with the proxy dialer
|
||||
connector.Dialer = (mssqlDialer)
|
||||
}
|
||||
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: rowLimit,
|
||||
}
|
||||
|
||||
queryResultTransformer := mssqlQueryResultTransformer{
|
||||
userError: userFacingDefaultError,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(),
|
||||
logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to Postgres", "err", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logger.Debug("Successfully connected to Postgres")
|
||||
return db, handler, nil
|
||||
}
|
||||
|
||||
func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc {
|
||||
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
grafCfg := backend.GrafanaConfigFromContext(ctx)
|
||||
@@ -151,14 +61,11 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
ConnectionTimeout: 0,
|
||||
SecureDSProxy: false,
|
||||
}
|
||||
azureCredentials, err := utils.GetAzureCredentials(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading azure credentials")
|
||||
}
|
||||
|
||||
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
|
||||
azureSettings, err := azsettings.ReadSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
|
||||
logger.Error("failed to read Azure settings from Grafana", "error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(settings.JSONData, &jsonData)
|
||||
@@ -181,23 +88,18 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
UID: settings.UID,
|
||||
DecryptedSecureJSONData: settings.DecryptedSecureJSONData,
|
||||
}
|
||||
cnnstr, err := generateConnectionString(dsInfo, cfg.Azure.ManagedIdentityClientId, cfg.Azure.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driverName := "mssql"
|
||||
if jsonData.AuthenticationType == azureAuthentication {
|
||||
driverName = "azuresql"
|
||||
}
|
||||
|
||||
userFacingDefaultError, err := grafCfg.UserFacingDefaultError()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, handler, err := newMSSQL(ctx, driverName, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings)
|
||||
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: sqlCfg.RowLimit,
|
||||
}
|
||||
handler, err := sqleng.NewQueryDataHandler(ctx, settings, userFacingDefaultError, config, logger, azureSettings)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
@@ -208,159 +110,6 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
}
|
||||
}
|
||||
|
||||
// ParseURL is called also from pkg/api/datasource/validation.go,
|
||||
// which uses a different logging interface,
|
||||
// so we have a special minimal interface that is fulfilled by
|
||||
// both places.
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an MSSQL URL string into a URL object.
|
||||
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing MSSQL URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateConnectionString(dsInfo sqleng.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 := 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 := 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
|
||||
}
|
||||
|
||||
func getAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
|
||||
connStr := ""
|
||||
switch c := azureCredentials.(type) {
|
||||
case *azcredentials.AzureManagedIdentityCredentials:
|
||||
if azureManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
|
||||
}
|
||||
connStr += fmt.Sprintf("fedauth=%s;",
|
||||
"ActiveDirectoryManagedIdentity")
|
||||
case *azcredentials.AzureClientSecretCredentials:
|
||||
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
|
||||
c.ClientId,
|
||||
c.TenantId,
|
||||
c.ClientSecret,
|
||||
"ActiveDirectoryApplication",
|
||||
)
|
||||
case *azcredentials.AzureEntraPasswordCredentials:
|
||||
if azureEntraPasswordCredentialsEnabled {
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
|
||||
c.UserId,
|
||||
c.Password,
|
||||
c.ClientId,
|
||||
"ActiveDirectoryPassword",
|
||||
)
|
||||
} else {
|
||||
return "", fmt.Errorf("azure entra password authentication is not enabled")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported azure authentication type")
|
||||
}
|
||||
return connStr, nil
|
||||
}
|
||||
|
||||
type mssqlQueryResultTransformer struct {
|
||||
userError string
|
||||
}
|
||||
|
||||
func (t *mssqlQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
|
||||
// go-mssql overrides source error, so we currently match on string
|
||||
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
|
||||
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
|
||||
logger.Error("Query error", "error", err)
|
||||
return fmt.Errorf("failed to connect to server - %s", t.userError)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckHealth pings the connected SQL database
|
||||
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext)
|
||||
@@ -370,100 +119,3 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
|
||||
|
||||
return dsHandler.CheckHealth(ctx, req)
|
||||
}
|
||||
|
||||
func (t *mssqlQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return []sqlutil.StringConverter{
|
||||
{
|
||||
Name: "handle MONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "MONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SMALLMONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "SMALLMONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle DECIMAL",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "DECIMAL",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle UNIQUEIDENTIFIER",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "UNIQUEIDENTIFIER",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
uuid := &mssql.UniqueIdentifier{}
|
||||
if err := uuid.Scan([]byte(*in)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := uuid.String()
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SQL_VARIANT",
|
||||
InputScanKind: reflect.Pointer,
|
||||
InputTypeName: "SQL_VARIANT",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+14
-273
@@ -3,21 +3,21 @@ package mssql
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
)
|
||||
|
||||
// To run this test, set runMssqlTests=true
|
||||
@@ -39,7 +39,6 @@ func TestMSSQL(t *testing.T) {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
@@ -50,8 +49,10 @@ func TestMSSQL(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
db := initMSSQLTestDB(t, config.DSInfo.JsonData)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
|
||||
fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
|
||||
@@ -799,14 +800,16 @@ func TestMSSQL(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("When doing a metric query using stored procedure should return correct result", func(t *testing.T) {
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: 1000000,
|
||||
}
|
||||
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
query := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
@@ -1202,7 +1205,6 @@ func TestMSSQL(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When row limit set to 1", func(t *testing.T) {
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
@@ -1210,7 +1212,10 @@ func TestMSSQL(t *testing.T) {
|
||||
RowLimit: 1,
|
||||
}
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) {
|
||||
@@ -1313,7 +1318,7 @@ func TestMSSQL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTransformQueryError(t *testing.T) {
|
||||
transformer := &mssqlQueryResultTransformer{}
|
||||
transformer := &utils.MSSQLQueryResultTransformer{}
|
||||
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
@@ -1334,252 +1339,6 @@ func TestTransformQueryError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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 sqleng.DataSourceInfo
|
||||
expConnStr string
|
||||
}{
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCache: "/tmp/krb5cc_1000",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "example.host",
|
||||
Database: "testDB",
|
||||
User: "testUser",
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost:1001",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.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: sqleng.DataSourceInfo{
|
||||
URL: "localhost:0",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "Defaults",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -1614,21 +1373,3 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim
|
||||
|
||||
return timeRange
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,18 +8,17 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
)
|
||||
|
||||
const rsIdentifier = `([_a-zA-Z0-9]+)`
|
||||
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
|
||||
|
||||
type msSQLMacroEngine struct {
|
||||
*sqleng.SQLMacroEngineBase
|
||||
*SQLMacroEngineBase
|
||||
}
|
||||
|
||||
func newMssqlMacroEngine() sqleng.SQLMacroEngine {
|
||||
return &msSQLMacroEngine{SQLMacroEngineBase: sqleng.NewSQLMacroEngineBase()}
|
||||
func newMssqlMacroEngine() SQLMacroEngine {
|
||||
return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()}
|
||||
}
|
||||
|
||||
func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange,
|
||||
@@ -79,7 +78,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := sqleng.SetupFillmode(query, interval, args[2])
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -114,7 +113,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := sqleng.SetupFillmode(query, interval, args[2])
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,18 @@ package utils
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
)
|
||||
|
||||
// GetJsonData just gets the json in easier to work with type. It's used on multiple places which isn't super effective
|
||||
@@ -26,3 +35,140 @@ func GetAzureCredentials(settings backend.DataSourceInstanceSettings) (azcredent
|
||||
}
|
||||
return azcredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData)
|
||||
}
|
||||
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an MSSQL URL string into a URL object.
|
||||
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing MSSQL URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type MSSQLQueryResultTransformer struct {
|
||||
UserError string
|
||||
}
|
||||
|
||||
func (t *MSSQLQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
|
||||
// go-mssql overrides source error, so we currently match on string
|
||||
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
|
||||
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
|
||||
logger.Error("Query error", "error", err)
|
||||
return fmt.Errorf("failed to connect to server - %s", t.UserError)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *MSSQLQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return []sqlutil.StringConverter{
|
||||
{
|
||||
Name: "handle MONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "MONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SMALLMONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "SMALLMONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle DECIMAL",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "DECIMAL",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle UNIQUEIDENTIFIER",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "UNIQUEIDENTIFIER",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
uuid := &mssql.UniqueIdentifier{}
|
||||
if err := uuid.Scan([]byte(*in)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := uuid.String()
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SQL_VARIANT",
|
||||
InputScanKind: reflect.Pointer,
|
||||
InputTypeName: "SQL_VARIANT",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user