Skip to content

Commit ba37cee

Browse files
authored
Add query args to be able to modify the current DB (#30)
1 parent cdfbeda commit ba37cee

File tree

7 files changed

+200
-46
lines changed

7 files changed

+200
-46
lines changed

datasource.go

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,32 @@ import (
1414
"github.com/grafana/grafana-plugin-sdk-go/data"
1515
)
1616

17+
const defaultKey = "_default"
18+
1719
type sqldatasource struct {
1820
Completable
1921

20-
db *sql.DB
21-
c Driver
22-
22+
dbConnections sync.Map
23+
c Driver
2324
driverSettings DriverSettings
2425
settings backend.DataSourceInstanceSettings
2526

2627
backend.CallResourceHandler
2728
CustomRoutes map[string]func(http.ResponseWriter, *http.Request)
29+
// Enabling multiple connections may cause that concurrent connection limits
30+
// are hit. The datasource enabling this should make sure connections are cached
31+
// if necessary.
32+
EnableMultipleConnections bool
2833
}
2934

3035
// NewDatasource creates a new `sqldatasource`.
3136
// It uses the provided settings argument to call the ds.Driver to connect to the SQL server
3237
func (ds *sqldatasource) NewDatasource(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
33-
db, err := ds.c.Connect(settings)
38+
db, err := ds.c.Connect(settings, nil)
3439
if err != nil {
3540
return nil, err
3641
}
37-
ds.db = db
42+
ds.dbConnections.Store(defaultKey, db)
3843
ds.settings = settings
3944
mux := http.NewServeMux()
4045
err = ds.registerRoutes(mux)
@@ -57,7 +62,14 @@ func NewDatasource(c Driver) *sqldatasource {
5762

5863
// Dispose cleans up datasource instance resources.
5964
func (ds *sqldatasource) Dispose() {
60-
ds.db.Close()
65+
ds.dbConnections.Range(func(key, db interface{}) bool {
66+
err := db.(*sql.DB).Close()
67+
if err != nil {
68+
backend.Logger.Error(err.Error())
69+
}
70+
ds.dbConnections.Delete(key)
71+
return true
72+
})
6173
}
6274

6375
// QueryData creates the Responses list and executes each query
@@ -88,6 +100,34 @@ func (ds *sqldatasource) QueryData(ctx context.Context, req *backend.QueryDataRe
88100

89101
}
90102

103+
func (ds *sqldatasource) getDB(q *Query) (*sql.DB, string, error) {
104+
// The database connection may vary depending on query arguments
105+
// The raw arguments are used as key to store the db connection in memory so they can be reused
106+
key := defaultKey
107+
db, ok := ds.dbConnections.Load(key)
108+
if !ok {
109+
return nil, "", fmt.Errorf("unable to get default db connection")
110+
}
111+
if !ds.EnableMultipleConnections || len(q.ConnectionArgs) == 0 {
112+
return db.(*sql.DB), key, nil
113+
}
114+
115+
key = string(q.ConnectionArgs)
116+
if cachedDB, ok := ds.dbConnections.Load(key); ok {
117+
return cachedDB.(*sql.DB), key, nil
118+
}
119+
120+
var err error
121+
db, err = ds.c.Connect(ds.settings, q.ConnectionArgs)
122+
if err != nil {
123+
return nil, "", err
124+
}
125+
// Assign this connection in the cache
126+
ds.dbConnections.Store(key, db)
127+
128+
return db.(*sql.DB), key, nil
129+
}
130+
91131
// handleQuery will call query, and attempt to reconnect if the query failed
92132
func (ds *sqldatasource) handleQuery(ctx context.Context, req backend.DataQuery) (data.Frames, error) {
93133
// Convert the backend.DataQuery into a Query object
@@ -108,6 +148,12 @@ func (ds *sqldatasource) handleQuery(ctx context.Context, req backend.DataQuery)
108148
fillMode = q.FillMissing
109149
}
110150

151+
// Retrieve the database connection
152+
db, cacheKey, err := ds.getDB(q)
153+
if err != nil {
154+
return nil, err
155+
}
156+
111157
if ds.driverSettings.Timeout != 0 {
112158
tctx, cancel := context.WithTimeout(ctx, ds.driverSettings.Timeout)
113159
defer cancel()
@@ -119,7 +165,7 @@ func (ds *sqldatasource) handleQuery(ctx context.Context, req backend.DataQuery)
119165
// * Some datasources (snowflake) expire connections or have an authentication token that expires if not used in 1 or 4 hours.
120166
// Because the datasource driver does not include an option for permanent connections, we retry the connection
121167
// if the query fails. NOTE: this does not include some errors like "ErrNoRows"
122-
res, err := query(ctx, ds.db, ds.c.Converters(), fillMode, q)
168+
res, err := query(ctx, db, ds.c.Converters(), fillMode, q)
123169
if err == nil {
124170
return res, nil
125171
}
@@ -129,20 +175,25 @@ func (ds *sqldatasource) handleQuery(ctx context.Context, req backend.DataQuery)
129175
}
130176

131177
if errors.Is(err, ErrorQuery) {
132-
ds.db, err = ds.c.Connect(ds.settings)
178+
db, err = ds.c.Connect(ds.settings, q.ConnectionArgs)
133179
if err != nil {
134180
return nil, err
135181
}
182+
ds.dbConnections.Store(cacheKey, db)
136183

137-
return query(ctx, ds.db, ds.c.Converters(), fillMode, q)
184+
return query(ctx, db, ds.c.Converters(), fillMode, q)
138185
}
139186

140187
return nil, err
141188
}
142189

143190
// CheckHealth pings the connected SQL database
144191
func (ds *sqldatasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
145-
if err := ds.db.Ping(); err != nil {
192+
db, ok := ds.dbConnections.Load(defaultKey)
193+
if !ok {
194+
return nil, fmt.Errorf("unable to get default db connection")
195+
}
196+
if err := db.(*sql.DB).Ping(); err != nil {
146197
return &backend.CheckHealthResult{
147198
Status: backend.HealthStatusError,
148199
Message: err.Error(),

datasource_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package sqlds
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/DATA-DOG/go-sqlmock"
9+
"github.com/grafana/grafana-plugin-sdk-go/backend"
10+
)
11+
12+
type fakeDriver struct {
13+
db *sql.DB
14+
15+
Driver
16+
}
17+
18+
func (d *fakeDriver) Connect(backend.DataSourceInstanceSettings, json.RawMessage) (db *sql.DB, err error) {
19+
return d.db, nil
20+
}
21+
22+
func Test_getDB(t *testing.T) {
23+
db := &sql.DB{}
24+
db2 := &sql.DB{}
25+
d := &fakeDriver{db: db2}
26+
tests := []struct {
27+
desc string
28+
args string
29+
existingDB *sql.DB
30+
expectedDB *sql.DB
31+
}{
32+
{
33+
"it should return the default db with no args",
34+
defaultKey,
35+
db,
36+
db,
37+
},
38+
{
39+
"it should return the cached connection for the given args",
40+
"foo",
41+
db,
42+
db,
43+
},
44+
{
45+
"it should create a new connection with the given args",
46+
"foo",
47+
nil,
48+
db2,
49+
},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.desc, func(t *testing.T) {
53+
ds := &sqldatasource{c: d, EnableMultipleConnections: true}
54+
if tt.existingDB != nil {
55+
ds.dbConnections.Store(tt.args, tt.existingDB)
56+
}
57+
if tt.args != defaultKey {
58+
// Add the mandatory default db
59+
ds.dbConnections.Store(defaultKey, db)
60+
}
61+
res, key, err := ds.getDB(&Query{ConnectionArgs: json.RawMessage(tt.args)})
62+
if err != nil {
63+
t.Fatalf("unexpected error %v", err)
64+
}
65+
if key != tt.args {
66+
t.Fatalf("unexpected cache key %s", key)
67+
}
68+
if res != tt.expectedDB {
69+
t.Fatalf("unexpected result %v", res)
70+
}
71+
})
72+
}
73+
}
74+
75+
func Test_Dispose(t *testing.T) {
76+
t.Run("it should close all db connections", func(t *testing.T) {
77+
db1, mock1, err := sqlmock.New()
78+
if err != nil {
79+
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
80+
}
81+
db2, mock2, err := sqlmock.New()
82+
if err != nil {
83+
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
84+
}
85+
86+
ds := &sqldatasource{}
87+
ds.dbConnections.Store(defaultKey, db1)
88+
ds.dbConnections.Store("foo", db2)
89+
90+
mock1.ExpectClose()
91+
mock2.ExpectClose()
92+
ds.Dispose()
93+
94+
err = mock1.ExpectationsWereMet()
95+
if err != nil {
96+
t.Error(err)
97+
}
98+
err = mock2.ExpectationsWereMet()
99+
if err != nil {
100+
t.Error(err)
101+
}
102+
103+
ds.dbConnections.Range(func(key, value interface{}) bool {
104+
t.Errorf("db connections were not deleted")
105+
return false
106+
})
107+
})
108+
}

driver.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sqlds
22

33
import (
44
"database/sql"
5+
"encoding/json"
56
"time"
67

78
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -18,7 +19,7 @@ type DriverSettings struct {
1819
// Plugin creators will need to implement this in order to create a managed datasource
1920
type Driver interface {
2021
// Connect connects to the database. It does not need to call `db.Ping()`
21-
Connect(backend.DataSourceInstanceSettings) (*sql.DB, error)
22+
Connect(backend.DataSourceInstanceSettings, json.RawMessage) (*sql.DB, error)
2223
// Settings are read whenever the plugin is initialized, or after the data source settings are updated
2324
Settings(backend.DataSourceInstanceSettings) DriverSettings
2425
Macros() Macros

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/grafana/sqlds
33
go 1.15
44

55
require (
6+
github.com/DATA-DOG/go-sqlmock v1.5.0
67
github.com/grafana/grafana-plugin-sdk-go v0.94.0
78
github.com/stretchr/testify v1.7.0
89
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
22
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
33
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
4+
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
5+
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
46
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
57
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
68
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=

macros_test.go

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
11
package sqlds
22

33
import (
4-
"database/sql"
54
"fmt"
65
"testing"
76
"time"
87

98
"github.com/grafana/grafana-plugin-sdk-go/backend"
10-
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
119
"github.com/stretchr/testify/assert"
1210
"github.com/stretchr/testify/require"
1311
)
1412

15-
type MockDB struct{}
16-
17-
func (h *MockDB) Connect(backend.DataSourceInstanceSettings) (db *sql.DB, err error) {
18-
return
19-
}
20-
21-
func (h *MockDB) Settings(backend.DataSourceInstanceSettings) (settings DriverSettings) {
22-
return
23-
}
24-
25-
func (h *MockDB) Converters() (sc []sqlutil.Converter) {
26-
return
13+
type MockDB struct {
14+
Driver
2715
}
2816

2917
func (h *MockDB) Macros() (macros Macros) {

query.go

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ const (
2626
// For the sake of backwards compatibility, when making changes to this type, ensure that changes are
2727
// only additive.
2828
type Query struct {
29-
RawSQL string `json:"rawSql"`
30-
Format FormatQueryOption `json:"format"`
29+
RawSQL string `json:"rawSql"`
30+
Format FormatQueryOption `json:"format"`
31+
ConnectionArgs json.RawMessage `json:"connectionArgs"`
3132

3233
RefID string `json:"-"`
3334
Interval time.Duration `json:"-"`
@@ -45,15 +46,16 @@ type Query struct {
4546
// This is mostly useful in the Interpolate function, where the RawSQL value is modified in a loop
4647
func (q *Query) WithSQL(query string) *Query {
4748
return &Query{
48-
RawSQL: query,
49-
RefID: q.RefID,
50-
Interval: q.Interval,
51-
TimeRange: q.TimeRange,
52-
MaxDataPoints: q.MaxDataPoints,
53-
FillMissing: q.FillMissing,
54-
Schema: q.Schema,
55-
Table: q.Table,
56-
Column: q.Column,
49+
RawSQL: query,
50+
ConnectionArgs: q.ConnectionArgs,
51+
RefID: q.RefID,
52+
Interval: q.Interval,
53+
TimeRange: q.TimeRange,
54+
MaxDataPoints: q.MaxDataPoints,
55+
FillMissing: q.FillMissing,
56+
Schema: q.Schema,
57+
Table: q.Table,
58+
Column: q.Column,
5759
}
5860
}
5961

@@ -67,16 +69,17 @@ func GetQuery(query backend.DataQuery) (*Query, error) {
6769

6870
// Copy directly from the well typed query
6971
return &Query{
70-
RawSQL: model.RawSQL,
71-
Format: model.Format,
72-
RefID: query.RefID,
73-
Interval: query.Interval,
74-
TimeRange: query.TimeRange,
75-
MaxDataPoints: query.MaxDataPoints,
76-
FillMissing: model.FillMissing,
77-
Schema: model.Schema,
78-
Table: model.Table,
79-
Column: model.Column,
72+
RawSQL: model.RawSQL,
73+
Format: model.Format,
74+
ConnectionArgs: model.ConnectionArgs,
75+
RefID: query.RefID,
76+
Interval: query.Interval,
77+
TimeRange: query.TimeRange,
78+
MaxDataPoints: query.MaxDataPoints,
79+
FillMissing: model.FillMissing,
80+
Schema: model.Schema,
81+
Table: model.Table,
82+
Column: model.Column,
8083
}, nil
8184
}
8285

0 commit comments

Comments
 (0)