diff --git a/pkg/services/store/auth.go b/pkg/services/store/auth.go index eda1011a911..af6fff1551b 100644 --- a/pkg/services/store/auth.go +++ b/pkg/services/store/auth.go @@ -11,6 +11,9 @@ func GetUserIDString(user *user.SignedInUser) string { // TODO: should we check IsDisabled? // TODO: could we use the NamespacedID.ID() as prefix instead of manually // setting "anon", "key", etc.? + // TODO: the default unauthenticated user is not anonymous and would be + // returned as `sys:0:` here. We may want to do something special in that + // case if user == nil { return "" } diff --git a/pkg/services/store/entity/db/dbimpl/dbEngine_test.go b/pkg/services/store/entity/db/dbimpl/dbEngine_test.go index 037652cb2ee..8c3a77412c0 100644 --- a/pkg/services/store/entity/db/dbimpl/dbEngine_test.go +++ b/pkg/services/store/entity/db/dbimpl/dbEngine_test.go @@ -1,49 +1,92 @@ package dbimpl import ( - "strings" "testing" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGetEnginePostgresFromConfig(t *testing.T) { - cfg := setting.NewCfg() - s, err := cfg.Raw.NewSection("entity_api") - require.NoError(t, err) - s.Key("db_type").SetValue("mysql") - s.Key("db_host").SetValue("localhost") - s.Key("db_name").SetValue("grafana") - s.Key("db_user").SetValue("user") - s.Key("db_password").SetValue("password") - - getter := §ionGetter{ - DynamicSection: cfg.SectionWithEnvOverrides("entity_api"), - } - engine, err := getEnginePostgres(getter, nil) - - assert.NotNil(t, engine) - assert.NoError(t, err) - assert.True(t, strings.Contains(engine.DataSourceName(), "dbname=grafana")) -} - func TestGetEngineMySQLFromConfig(t *testing.T) { - cfg := setting.NewCfg() - s, err := cfg.Raw.NewSection("entity_api") - require.NoError(t, err) - s.Key("db_type").SetValue("mysql") - s.Key("db_host").SetValue("localhost") - s.Key("db_name").SetValue("grafana") - s.Key("db_user").SetValue("user") - s.Key("db_password").SetValue("password") + t.Parallel() - getter := §ionGetter{ - DynamicSection: cfg.SectionWithEnvOverrides("entity_api"), - } - engine, err := getEngineMySQL(getter, nil) + t.Run("happy path", func(t *testing.T) { + t.Parallel() - assert.NotNil(t, engine) - assert.NoError(t, err) + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "/var/run/mysql.socket", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEngineMySQL(getter, nil) + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("invalid string", func(t *testing.T) { + t.Parallel() + + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "/var/run/mysql.socket", + "db_name": string(invalidUTF8ByteSequence), + "db_user": "user", + "db_password": "password", + }) + engine, err := getEngineMySQL(getter, nil) + assert.Nil(t, engine) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidUTF8Sequence) + }) +} + +func TestGetEnginePostgresFromConfig(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "localhost", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("invalid string", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": string(invalidUTF8ByteSequence), + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.Nil(t, engine) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidUTF8Sequence) + }) + + t.Run("invalid hostport", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "1:1:1", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.Nil(t, engine) + assert.Error(t, err) + }) } diff --git a/pkg/services/store/entity/db/dbimpl/db_test.go b/pkg/services/store/entity/db/dbimpl/db_test.go index 2a6b93a871a..943f025cf90 100644 --- a/pkg/services/store/entity/db/dbimpl/db_test.go +++ b/pkg/services/store/entity/db/dbimpl/db_test.go @@ -2,6 +2,7 @@ package dbimpl import ( "context" + "errors" "testing" "time" @@ -29,6 +30,44 @@ func newCtx(t *testing.T) context.Context { return ctx } +var errTest = errors.New("because of reasons") + +const driverName = "sqlmock" + +func TestDB_BeginTx(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, driverName) + require.Equal(t, driverName, db.DriverName()) + + mock.ExpectBegin() + tx, err := db.BeginTx(newCtx(t), nil) + + require.NoError(t, err) + require.NotNil(t, tx) + }) + + t.Run("fail begin", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin().WillReturnError(errTest) + tx, err := db.BeginTx(newCtx(t), nil) + + require.Nil(t, tx) + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) +} + func TestDB_WithTx(t *testing.T) { t.Parallel() @@ -51,4 +90,65 @@ func TestDB_WithTx(t *testing.T) { require.NoError(t, err) }) + + t.Run("fail begin", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(nil)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) + + t.Run("fail tx", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin() + mock.ExpectRollback() + err = db.WithTx(newCtx(t), nil, newTxFunc(errTest)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) + + t.Run("fail tx; fail rollback", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + errTest2 := errors.New("yet another err") + + mock.ExpectBegin() + mock.ExpectRollback().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(errTest2)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + require.ErrorIs(t, err, errTest2) + }) + + t.Run("fail commit", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin() + mock.ExpectCommit().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(nil)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) } diff --git a/pkg/services/store/entity/db/dbimpl/dbimpl.go b/pkg/services/store/entity/db/dbimpl/dbimpl.go index 457334faf06..c3edfa287ab 100644 --- a/pkg/services/store/entity/db/dbimpl/dbimpl.go +++ b/pkg/services/store/entity/db/dbimpl/dbimpl.go @@ -44,15 +44,18 @@ type EntityDB struct { } func (db *EntityDB) Init() error { - _, err := db.GetEngine() - return err -} - -func (db *EntityDB) GetEngine() (*xorm.Engine, error) { db.once.Do(func() { db.onceErr = db.init() }) + return db.onceErr +} + +func (db *EntityDB) GetEngine() (*xorm.Engine, error) { + if err := db.Init(); err != nil { + return nil, err + } + return db.engine, db.onceErr } diff --git a/pkg/services/store/entity/db/dbimpl/util.go b/pkg/services/store/entity/db/dbimpl/util.go index d9863a55ff6..44ff1138236 100644 --- a/pkg/services/store/entity/db/dbimpl/util.go +++ b/pkg/services/store/entity/db/dbimpl/util.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "sort" "strings" "unicode/utf8" @@ -25,9 +26,6 @@ func (g *sectionGetter) Err() error { } func (g *sectionGetter) String(key string) string { - if g.err != nil { - return "" - } v := g.DynamicSection.Key(key).MustString("") if !utf8.ValidString(v) { g.err = fmt.Errorf("value for key %q: %w", key, ErrInvalidUTF8Sequence) @@ -43,7 +41,10 @@ func (g *sectionGetter) String(key string) string { func MakeDSN(m map[string]string) (string, error) { b := new(strings.Builder) - for k, v := range m { + ks := keys(m) + sort.Strings(ks) // provide deterministic behaviour + for _, k := range ks { + v := m[k] if !utf8.ValidString(v) { return "", fmt.Errorf("value for DSN key %q: %w", k, ErrInvalidUTF8Sequence) @@ -63,6 +64,14 @@ func MakeDSN(m map[string]string) (string, error) { return b.String(), nil } +func keys(m map[string]string) []string { + ret := make([]string, 0, len(m)) + for k := range m { + ret = append(ret, k) + } + return ret +} + func writeDSNValue(b *strings.Builder, v string) { numq := strings.Count(v, `'`) numb := strings.Count(v, `\`) @@ -83,6 +92,9 @@ func writeDSNValue(b *strings.Builder, v string) { _ = b.WriteByte('\'') } +// splitHostPortDefault is similar to net.SplitHostPort, but will also accept a +// specification with no port and apply the default port instead. It also +// applies the given defaults if the results are empty strings. func splitHostPortDefault(hostport, defaultHost, defaultPort string) (string, string, error) { host, port, err := net.SplitHostPort(hostport) if err != nil { diff --git a/pkg/services/store/entity/db/dbimpl/util_test.go b/pkg/services/store/entity/db/dbimpl/util_test.go new file mode 100644 index 00000000000..cb3a76aeee7 --- /dev/null +++ b/pkg/services/store/entity/db/dbimpl/util_test.go @@ -0,0 +1,108 @@ +package dbimpl + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +var invalidUTF8ByteSequence = []byte{0xff, 0xfe, 0xfd} + +func setSectionKeyValues(section *setting.DynamicSection, m map[string]string) { + for k, v := range m { + section.Key(k).SetValue(v) + } +} + +func newTestSectionGetter(m map[string]string) *sectionGetter { + section := setting.NewCfg().SectionWithEnvOverrides("entity_api") + setSectionKeyValues(section, m) + + return §ionGetter{ + DynamicSection: section, + } +} + +func TestSectionGetter(t *testing.T) { + t.Parallel() + + var ( + key = "the key" + val = string(invalidUTF8ByteSequence) + ) + + g := newTestSectionGetter(map[string]string{ + key: val, + }) + + v := g.String("whatever") + require.Empty(t, v) + require.NoError(t, g.Err()) + + v = g.String(key) + require.Empty(t, v) + require.Error(t, g.Err()) + require.ErrorIs(t, g.Err(), ErrInvalidUTF8Sequence) +} + +func TestMakeDSN(t *testing.T) { + t.Parallel() + + s, err := MakeDSN(map[string]string{ + "db_name": string(invalidUTF8ByteSequence), + }) + require.Empty(t, s) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidUTF8Sequence) + + s, err = MakeDSN(map[string]string{ + "skip": "", + "user": `shou'ld esc\ape`, + "pass": "noescape", + }) + require.NoError(t, err) + require.Equal(t, `pass=noescape user='shou\'ld esc\\ape'`, s) +} + +func TestSplitHostPort(t *testing.T) { + t.Parallel() + + testCases := []struct { + hostport string + defaultHost string + defaultPort string + fails bool + + host string + port string + }{ + {hostport: "192.168.0.140:456", defaultHost: "", defaultPort: "", host: "192.168.0.140", port: "456"}, + {hostport: "192.168.0.140", defaultHost: "", defaultPort: "123", host: "192.168.0.140", port: "123"}, + {hostport: "[::1]:456", defaultHost: "", defaultPort: "", host: "::1", port: "456"}, + {hostport: "[::1]", defaultHost: "", defaultPort: "123", host: "::1", port: "123"}, + {hostport: ":456", defaultHost: "1.2.3.4", defaultPort: "", host: "1.2.3.4", port: "456"}, + {hostport: "xyz.rds.amazonaws.com", defaultHost: "", defaultPort: "123", host: "xyz.rds.amazonaws.com", port: "123"}, + {hostport: "xyz.rds.amazonaws.com:123", defaultHost: "", defaultPort: "", host: "xyz.rds.amazonaws.com", port: "123"}, + {hostport: "", defaultHost: "localhost", defaultPort: "1433", host: "localhost", port: "1433"}, + {hostport: "1:1:1", fails: true}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test index #%d", i), func(t *testing.T) { + t.Parallel() + + host, port, err := splitHostPortDefault(tc.hostport, tc.defaultHost, tc.defaultPort) + if tc.fails { + require.Error(t, err) + require.Empty(t, host) + require.Empty(t, port) + } else { + require.NoError(t, err) + require.Equal(t, tc.host, host) + require.Equal(t, tc.port, port) + } + }) + } +} diff --git a/pkg/services/store/entity/sqlstash/data/entity_history.sql b/pkg/services/store/entity/sqlstash/data/entity_history.sql deleted file mode 100644 index 70631968b9a..00000000000 --- a/pkg/services/store/entity/sqlstash/data/entity_history.sql +++ /dev/null @@ -1,30 +0,0 @@ -SELECT {{ template "common_entity_select_into" . }} - - FROM {{ .Ident "entity_history" }} AS e - - WHERE 1 = 1 - - {{ if gt .Before 0 }} - AND {{ .Ident "resource_version" }} < {{ .Arg .Before }} - {{ end }} - - {{/* There are two mutually exclusive search modes: by GUID and by Key */}} - - {{ if ne .Query.GUID "" }} - AND {{ .Ident "guid" }} = {{ .Arg .Query.GUID }} - - {{ else }} - AND {{ .Ident "group" }} = {{ .Arg .Query.Key.Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Query.Key.Resource }} - AND {{ .Ident "name" }} = {{ .Arg .Query.Key.Name }} - - {{ if ne .Query.Key.Namespace "" }} - AND {{ .Ident "namespace" }} = {{ .Arg .Query.Key.Namespace }} - {{ end }} - - {{ end }} - - ORDER BY {{ template "common_order_by" . }} - LIMIT {{ .Limit }} - OFFSET {{ .Offset }} -; diff --git a/pkg/services/store/entity/sqlstash/data/entity_ref_find.sql b/pkg/services/store/entity/sqlstash/data/entity_ref_find.sql deleted file mode 100644 index 0f8042761e6..00000000000 --- a/pkg/services/store/entity/sqlstash/data/entity_ref_find.sql +++ /dev/null @@ -1,14 +0,0 @@ -SELECT {{ template "common_entity_select_into" . }} - - FROM - {{ .Ident "entity_ref" }} AS r - INNER JOIN - {{ .Ident "entity" }} AS e - ON r.{{ .Ident "guid" }} = e.{{ .Ident "guid" }} - - WHERE 1 = 1 - AND r.{{ .Ident "namespace" }} = {{ .Arg .Request.Namespace }} - AND r.{{ .Ident "group" }} = {{ .Arg .Request.Group }} - AND r.{{ .Ident "resource" }} = {{ .Arg .Request.Resource }} - AND r.{{ .Ident "resolved_to" }} = {{ .Arg .Request.Name }} -; diff --git a/pkg/services/store/entity/sqlstash/folder_support.go b/pkg/services/store/entity/sqlstash/folder_support.go index eb5fd5014d2..2b264804819 100644 --- a/pkg/services/store/entity/sqlstash/folder_support.go +++ b/pkg/services/store/entity/sqlstash/folder_support.go @@ -100,8 +100,8 @@ func (s *sqlEntityServer) updateFolderTree(ctx context.Context, x db.ContextExec itemList = append(itemList, &fi) } - if err := rows.Close(); err != nil { - return fmt.Errorf("close rows after listing folder items in namespace %q: %w", namespace, err) + if err := rows.Err(); err != nil { + return fmt.Errorf("rows error after listing folder items in namespace %q: %w", namespace, err) } root, lost, err := buildFolderTree(itemList) diff --git a/pkg/services/store/entity/sqlstash/queries.go b/pkg/services/store/entity/sqlstash/queries.go index e2dc77c2935..e12bf819282 100644 --- a/pkg/services/store/entity/sqlstash/queries.go +++ b/pkg/services/store/entity/sqlstash/queries.go @@ -40,18 +40,14 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlEntityDelete = mustTemplate("entity_delete.sql") - sqlEntityHistory = mustTemplate("entity_history.sql") - //sqlEntityHistoryList = mustTemplate("entity_history_list.sql") // TODO: in upcoming PRs + sqlEntityDelete = mustTemplate("entity_delete.sql") sqlEntityInsert = mustTemplate("entity_insert.sql") sqlEntityListFolderElements = mustTemplate("entity_list_folder_elements.sql") - sqlEntityUpdate = mustTemplate("entity_update.sql") sqlEntityRead = mustTemplate("entity_read.sql") + sqlEntityUpdate = mustTemplate("entity_update.sql") sqlEntityFolderInsert = mustTemplate("entity_folder_insert.sql") - sqlEntityRefFind = mustTemplate("entity_ref_find.sql") - sqlEntityLabelsDelete = mustTemplate("entity_labels_delete.sql") sqlEntityLabelsInsert = mustTemplate("entity_labels_insert.sql") @@ -75,12 +71,18 @@ var ( // SQLError is an error returned by the database, which includes additionally // debugging information about what was sent to the database. type SQLError struct { - Err error - CallType string // either Query, QueryRow or Exec - Arguments []any - ScanDest []any - Query string - RawQuery string + Err error + CallType string // either Query, QueryRow or Exec + TemplateName string + Query string + RawQuery string + ScanDest []any + + // potentially regulated information is not exported and only directly + // available for local testing and local debugging purposes, making sure it + // is never marshaled to JSON or any other serialization. + + arguments []any } func (e SQLError) Unwrap() error { @@ -88,20 +90,9 @@ func (e SQLError) Unwrap() error { } func (e SQLError) Error() string { - return fmt.Sprintf("calling %s in database: %v", e.CallType, e.Err) -} - -func (e SQLError) Debug() string { - scanDestStr := "(none)" - if len(e.ScanDest) > 0 { - format := "[%T" + strings.Repeat(", %T", len(e.ScanDest)-1) + "]" - scanDestStr = fmt.Sprintf(format, e.ScanDest...) - } - - return fmt.Sprintf("call %s in database: %v\n\tArguments (%d): %#v\n\t"+ - "Return Value Types (%d): %s\n\tExecuted Query: %s\n\tRaw SQL "+ - "Template Output: %s", e.CallType, e.Err, len(e.Arguments), e.Arguments, - len(e.ScanDest), scanDestStr, e.Query, e.RawQuery) + return fmt.Sprintf("%s: %s with %d input arguments and %d output "+ + "destination arguments: %v", e.TemplateName, e.CallType, + len(e.arguments), len(e.ScanDest), e.Err) } // entity_folder table requests. @@ -111,6 +102,10 @@ type sqlEntityFolderInsertRequest struct { Items []*sqlEntityFolderInsertRequestItem } +func (r sqlEntityFolderInsertRequest) Validate() error { + return nil // TODO +} + type sqlEntityFolderInsertRequestItem struct { GUID string Namespace string @@ -123,14 +118,6 @@ type sqlEntityFolderInsertRequestItem struct { Detached bool } -// entity_ref table requests. - -type sqlEntityRefFindRequest struct { - *sqltemplate.SQLTemplate - Request *entity.ReferenceRequest - returnsEntitySet -} - // entity_labels table requests. type sqlEntityLabelsInsertRequest struct { @@ -139,12 +126,20 @@ type sqlEntityLabelsInsertRequest struct { Labels map[string]string } +func (r sqlEntityLabelsInsertRequest) Validate() error { + return nil // TODO +} + type sqlEntityLabelsDeleteRequest struct { *sqltemplate.SQLTemplate GUID string KeepLabels []string } +func (r sqlEntityLabelsDeleteRequest) Validate() error { + return nil // TODO +} + // entity_kind table requests. type returnsKindVersion struct { @@ -164,6 +159,10 @@ type sqlKindVersionGetRequest struct { *returnsKindVersion } +func (r sqlKindVersionGetRequest) Validate() error { + return nil // TODO +} + type sqlKindVersionLockRequest struct { *sqltemplate.SQLTemplate Group string @@ -171,6 +170,10 @@ type sqlKindVersionLockRequest struct { *returnsKindVersion } +func (r sqlKindVersionLockRequest) Validate() error { + return nil // TODO +} + type sqlKindVersionIncRequest struct { *sqltemplate.SQLTemplate Group string @@ -179,6 +182,10 @@ type sqlKindVersionIncRequest struct { UpdatedAt int64 } +func (r sqlKindVersionIncRequest) Validate() error { + return nil // TODO +} + type sqlKindVersionInsertRequest struct { *sqltemplate.SQLTemplate Group string @@ -187,6 +194,10 @@ type sqlKindVersionInsertRequest struct { UpdatedAt int64 } +func (r sqlKindVersionInsertRequest) Validate() error { + return nil // TODO +} + // entity and entity_history tables requests. type sqlEntityListFolderElementsRequest struct { @@ -197,6 +208,10 @@ type sqlEntityListFolderElementsRequest struct { FolderInfo *folderInfo } +func (r sqlEntityListFolderElementsRequest) Validate() error { + return nil // TODO +} + // sqlEntityReadRequest can be used to retrieve a row from either the "entity" // or the "entity_history" tables. In particular, don't use this template // directly. Instead, use the readEntity function, which provides all common use @@ -209,21 +224,17 @@ type sqlEntityReadRequest struct { returnsEntitySet } +func (r sqlEntityReadRequest) Validate() error { + return nil // TODO +} + type sqlEntityDeleteRequest struct { *sqltemplate.SQLTemplate Key *entity.Key } -type sqlEntityHistoryRequest struct { - *sqltemplate.SQLTemplate - //historyToken // TODO: coming in another PR - returnsEntitySet -} - -type sqlEntityHistoryListRequest struct { - *sqltemplate.SQLTemplate - //hitoryListToken // TODO: coming in another PR - returnsEntitySet +func (r sqlEntityDeleteRequest) Validate() error { + return nil // TODO } type sqlEntityInsertRequest struct { @@ -235,16 +246,33 @@ type sqlEntityInsertRequest struct { TableEntity bool } +func (r sqlEntityInsertRequest) Validate() error { + return nil // TODO +} + type sqlEntityUpdateRequest struct { *sqltemplate.SQLTemplate Entity *returnsEntity } +func (r sqlEntityUpdateRequest) Validate() error { + return nil // TODO +} + +// newEmptyEntity allocates a new entity.Entity and all its internal state to be +// ready for use. func newEmptyEntity() *entity.Entity { return &entity.Entity{ // we need to allocate all internal pointer types so that they // are readily available to be populated in the template Origin: new(entity.EntityOriginInfo), + + // we also set default empty values in slices and maps instead of nil to + // provide the most consistent JSON representation fields that will be + // serialized this way to the database. + Labels: map[string]string{}, + Fields: map[string]string{}, + Errors: []*entity.EntityErrorInfo{}, } } @@ -258,6 +286,36 @@ func cloneEntity(src *entity.Entity) *entity.Entity { // returnsEntitySet can be embedded in a request struct to provide automatic set // returning of []*entity.Entity from the database, deserializing as needed. It // should be embedded as a value type. +// Example struct: +// +// type sqlMyRequest struct { +// *sqltemplate.SQLTemplate +// returnsEntitySet // embedded value type, not pointer type +// GUID string // example argument +// MaxResourceVersion int // example argument +// } +// +// Example struct usage:: +// +// req := sqlMyRequest{ +// SQLTemplate: sqltemplate.New(myDialect), +// returnsEntitySet: newReturnsEntitySet(), +// GUID: "abc", +// MaxResourceVersion: 1, +// } +// entities, err := query(myTx, myTmpl, req) +// +// Example usage in SQL template: +// +// SELECT +// {{ .Ident "guid" | .Into .Entity.Guid }}, +// {{ .Ident "resource_version" | .Into .Entity.ResourceVersion }}, +// {{ .Ident "body" | .Into .Entity.Body }} +// FROM {{ .Ident "entity_history" }} +// WHERE 1 = 1 +// AND {{ .Ident "guid" }} = {{ .Arg .GUID }} +// AND {{ .Ident "resource_version" }} <= {{ .Arg .MaxResourceVersion }} +// ; type returnsEntitySet struct { Entity *returnsEntity } @@ -278,13 +336,46 @@ func (e returnsEntitySet) Results() (*entity.Entity, error) { return nil, err } - return proto.Clone(ent).(*entity.Entity), nil + return cloneEntity(ent), nil } // returnsEntity is a wrapper that aids with database (de)serialization. It // embeds a *entity.Entity to provide transparent access to all its fields, but // overrides the ones that need database (de)serialization. It should be a named // field in your request struct, with pointer type. +// Example struct: +// +// type sqlMyRequest struct { +// *sqltemplate.SQLTemplate +// Entity *returnsEntity // named field with pointer type +// GUID string // example argument +// ResourceVersion int // example argument +// } +// +// Example struct usage: +// +// req := sqlMyRequest{ +// SQLTemplate: sqltemplate.New(myDialect), +// Entity: newReturnsEntity(), +// GUID: "abc", +// ResourceVersion: 1, +// } +// err := queryRow(myTx, myTmpl, req) +// // check err here +// err = req.Entity.unmarshal() +// // check err, and you can now use req.Entity.Entity +// +// Example usage in SQL template: +// +// SELECT +// {{ .Ident "guid" | .Into .Entity.Guid }}, +// {{ .Ident "resource_version" | .Into .Entity.ResourceVersion }}, +// {{ .Ident "body" | .Into .Entity.Body }} +// FROM {{ .Ident "entity" }} +// WHERE 1 =1 +// AND {{ .Ident "guid" }} = {{ .Arg .GUID }} +// AND {{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} +// ; type returnsEntity struct { *entity.Entity Labels []byte @@ -348,23 +439,42 @@ func (e *returnsEntity) unmarshal() error { if err := json.Unmarshal(e.Labels, &e.Entity.Labels); err != nil { return fmt.Errorf("deserialize entity \"labels\" field: %w", err) } + } else { + e.Entity.Labels = map[string]string{} } if len(e.Fields) > 0 { if err := json.Unmarshal(e.Fields, &e.Entity.Fields); err != nil { return fmt.Errorf("deserialize entity \"fields\" field: %w", err) } + } else { + e.Entity.Fields = map[string]string{} } if len(e.Errors) > 0 { if err := json.Unmarshal(e.Errors, &e.Entity.Errors); err != nil { return fmt.Errorf("deserialize entity \"errors\" field: %w", err) } + } else { + e.Entity.Errors = []*entity.EntityErrorInfo{} } return nil } +// readEntity returns the entity defined by the given key as it existed at +// version `asOfVersion`, if that value is greater than zero. The returned +// entity will have at most that version. If `asOfVersion` is zero, then the +// current version of that entity will be returned. If `optimisticLocking` is +// true, then the latest version of the entity will be retrieved and return an +// error if its version is not exactly `asOfVersion`. The option +// `selectForUpdate` will cause to acquire a row-level exclusive lock upon +// selecting it. `optimisticLocking` is ignored if `asOfVersion` is zero. +// Common errors to check: +// 1. ErrOptimisticLockingFailed: the latest version of the entity does not +// match the value of `asOfVersion`. +// 2. ErrNotFound: the entity does not currently exist, did not exist at the +// version of `asOfVersion` or was deleted. func readEntity( ctx context.Context, x db.ContextExecer, @@ -374,12 +484,8 @@ func readEntity( optimisticLocking bool, selectForUpdate bool, ) (*returnsEntity, error) { - if asOfVersion < 0 { - asOfVersion = 0 - } - if asOfVersion == 0 { - optimisticLocking = false - } + asOfVersion = max(asOfVersion, 0) + optimisticLocking = optimisticLocking && asOfVersion != 0 v := asOfVersion if optimisticLocking { @@ -437,7 +543,7 @@ func kindVersionAtomicInc(ctx context.Context, x db.ContextExecer, d sqltemplate // a new (Group, Resource) to the cell, which should be very unlikely, // and the workaround is simply retrying. The alternative would be to // use INSERT ... ON CONFLICT DO UPDATE ..., but that creates a - // requirement for support in Dialect only for this marginal case, but + // requirement for support in Dialect only for this marginal case, and // we would rather keep Dialect as small as possible. Another // alternative is to simply check if the INSERT returns a DUPLICATE KEY // error and then retry the original SELECT, but that also adds some diff --git a/pkg/services/store/entity/sqlstash/queries_test.go b/pkg/services/store/entity/sqlstash/queries_test.go new file mode 100644 index 00000000000..7d6583c8ead --- /dev/null +++ b/pkg/services/store/entity/sqlstash/queries_test.go @@ -0,0 +1,821 @@ +package sqlstash + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "text/template" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" + "github.com/grafana/grafana/pkg/util/testutil" +) + +// debug is meant to provide greater debugging detail about certain errors. The +// returned error will either provide more detailed information or be the same +// original error, suitable only for local debugging. The details provided are +// not meant to be logged, since they could include PII or otherwise +// sensitive/confidential information. These information should only be used for +// local debugging with fake or otherwise non-regulated information. +func debug(err error) error { + var d interface{ Debug() string } + if errors.As(err, &d) { + return errors.New(d.Debug()) + } + + return err +} + +var _ = debug // silence the `unused` linter + +//go:embed testdata/* +var testdataFS embed.FS + +func testdata(t *testing.T, filename string) []byte { + t.Helper() + b, err := testdataFS.ReadFile(`testdata/` + filename) + require.NoError(t, err) + + return b +} + +func testdataJSON(t *testing.T, filename string, dest any) { + t.Helper() + b := testdata(t, filename) + err := json.Unmarshal(b, dest) + require.NoError(t, err) +} + +func TestQueries(t *testing.T) { + t.Parallel() + + // Each template has one or more test cases, each identified with a + // descriptive name (e.g. "happy path", "error twiddling the frobb"). Each + // of them will test that for the same input data they must produce a result + // that will depend on the Dialect. Expected queries should be defined in + // separate files in the testdata directory. This improves the testing + // experience by separating test data from test code, since mixing both + // tends to make it more difficult to reason about what is being done, + // especially as we want testing code to scale and make it easy to add + // tests. + type ( + // type aliases to make code more semantic and self-documenting + resultSQLFilename = string + dialects = []sqltemplate.Dialect + expected map[resultSQLFilename]dialects + + testCase = struct { + Name string + + // Data should be the struct passed to the template. + Data sqltemplate.SQLTemplateIface + + // Expected maps the filename containing the expected result query + // to the list of dialects that would produce it. For simple + // queries, it is possible that more than one dialect produce the + // same output. The filename is expected to be in the `testdata` + // directory. + Expected expected + } + ) + + // Define tests cases. Most templates are trivial and testing that they + // generate correct code for a single Dialect is fine, since the one thing + // that always changes is how SQL placeholder arguments are passed (most + // Dialects use `?` while PostgreSQL uses `$1`, `$2`, etc.), and that is + // something that should be tested in the Dialect implementation instead of + // here. We will ask to have at least one test per SQL template, and we will + // lean to test MySQL. Templates containing branching (conditionals, loops, + // etc.) should be exercised at least once in each of their branches. + // + // NOTE: in the Data field, make sure to have pointers populated to simulate + // data is set as it would be in a real request. The data being correctly + // populated in each case should be tested in integration tests, where the + // data will actually flow to and from a real database. In this tests we + // only care about producing the correct SQL. + testCases := map[*template.Template][]*testCase{ + sqlEntityDelete: { + { + Name: "single path", + Data: &sqlEntityDeleteRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: new(entity.Key), + }, + Expected: expected{ + "entity_delete_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + "entity_delete_postgres.sql": dialects{ + sqltemplate.PostgreSQL, + }, + }, + }, + }, + + sqlEntityInsert: { + { + Name: "insert into entity", + Data: &sqlEntityInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Entity: newReturnsEntity(), + TableEntity: true, + }, + Expected: expected{ + "entity_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + { + Name: "insert into entity_history", + Data: &sqlEntityInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Entity: newReturnsEntity(), + TableEntity: false, + }, + Expected: expected{ + "entity_history_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlEntityListFolderElements: { + { + Name: "single path", + Data: &sqlEntityListFolderElementsRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + FolderInfo: new(folderInfo), + }, + Expected: expected{ + "entity_list_folder_elements_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlEntityRead: { + { + Name: "with resource version and select for update", + Data: &sqlEntityReadRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: new(entity.Key), + ResourceVersion: 1, + SelectForUpdate: true, + returnsEntitySet: returnsEntitySet{ + Entity: newReturnsEntity(), + }, + }, + Expected: expected{ + "entity_history_read_full_mysql.sql": dialects{ + sqltemplate.MySQL, + }, + }, + }, + { + Name: "without resource version and select for update", + Data: &sqlEntityReadRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: new(entity.Key), + returnsEntitySet: returnsEntitySet{ + Entity: newReturnsEntity(), + }, + }, + Expected: expected{ + "entity_read_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlEntityUpdate: { + { + Name: "single path", + Data: &sqlEntityUpdateRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Entity: newReturnsEntity(), + }, + Expected: expected{ + "entity_update_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlEntityFolderInsert: { + { + Name: "one item", + Data: &sqlEntityFolderInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Items: []*sqlEntityFolderInsertRequestItem{{}}, + }, + Expected: expected{ + "entity_folder_insert_1_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + }, + }, + }, + { + Name: "two items", + Data: &sqlEntityFolderInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Items: []*sqlEntityFolderInsertRequestItem{{}, {}}, + }, + Expected: expected{ + "entity_folder_insert_2_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + }, + }, + }, + }, + + sqlEntityLabelsDelete: { + { + Name: "one element", + Data: &sqlEntityLabelsDeleteRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + KeepLabels: []string{"one"}, + }, + Expected: expected{ + "entity_labels_delete_1_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + { + Name: "two elements", + Data: &sqlEntityLabelsDeleteRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + KeepLabels: []string{"one", "two"}, + }, + Expected: expected{ + "entity_labels_delete_2_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlEntityLabelsInsert: { + { + Name: "one element", + Data: &sqlEntityLabelsInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Labels: map[string]string{"lbl1": "val1"}, + }, + Expected: expected{ + "entity_labels_insert_1_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + { + Name: "two elements", + Data: &sqlEntityLabelsInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Labels: map[string]string{"lbl1": "val1", "lbl2": "val2"}, + }, + Expected: expected{ + "entity_labels_insert_2_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlKindVersionGet: { + { + Name: "single path", + Data: &sqlKindVersionGetRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + returnsKindVersion: new(returnsKindVersion), + }, + Expected: expected{ + "kind_version_get_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlKindVersionInc: { + { + Name: "single path", + Data: &sqlKindVersionIncRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + }, + Expected: expected{ + "kind_version_inc_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlKindVersionInsert: { + { + Name: "single path", + Data: &sqlKindVersionInsertRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + }, + Expected: expected{ + "kind_version_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlKindVersionLock: { + { + Name: "single path", + Data: &sqlKindVersionLockRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + returnsKindVersion: new(returnsKindVersion), + }, + Expected: expected{ + "kind_version_lock_mysql.sql": dialects{ + sqltemplate.MySQL, + }, + "kind_version_lock_postgres.sql": dialects{ + sqltemplate.PostgreSQL, + }, + "kind_version_lock_sqlite.sql": dialects{ + sqltemplate.SQLite, + }, + }, + }, + }, + } + + // Execute test cases + for tmpl, tcs := range testCases { + t.Run(tmpl.Name(), func(t *testing.T) { + t.Parallel() + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + for filename, ds := range tc.Expected { + t.Run(filename, func(t *testing.T) { + // not parallel because we're sharing tc.Data, not + // worth it deep cloning + + rawQuery := string(testdata(t, filename)) + expectedQuery := sqltemplate.FormatSQL(rawQuery) + + for _, d := range ds { + t.Run(d.Name(), func(t *testing.T) { + // not parallel for the same reason + + tc.Data.SetDialect(d) + err := tc.Data.Validate() + require.NoError(t, err) + got, err := sqltemplate.Execute(tmpl, tc.Data) + require.NoError(t, err) + got = sqltemplate.FormatSQL(got) + require.Equal(t, expectedQuery, got) + }) + } + }) + } + }) + } + }) + } +} + +func TestReturnsEntity_marshal(t *testing.T) { + t.Parallel() + + // test data for maps + someMap := map[string]string{ + "alpha": "aleph", + "beta": "beth", + } + someMapJSONb, err := json.Marshal(someMap) + require.NoError(t, err) + someMapJSON := string(someMapJSONb) + + // test data for errors + someErrors := []*entity.EntityErrorInfo{ + { + Code: 1, + Message: "not cool", + DetailsJson: []byte(`"nothing to add"`), + }, + } + someErrorsJSONb, err := json.Marshal(someErrors) + require.NoError(t, err) + someErrorsJSON := string(someErrorsJSONb) + + t.Run("happy path - nothing to marshal", func(t *testing.T) { + t.Parallel() + + d := &returnsEntity{ + Entity: &entity.Entity{ + Labels: map[string]string{}, + Fields: map[string]string{}, + Errors: []*entity.EntityErrorInfo{}, + }, + } + err := d.marshal() + require.NoError(t, err) + + require.JSONEq(t, `{}`, string(d.Labels)) + require.JSONEq(t, `{}`, string(d.Fields)) + require.JSONEq(t, `[]`, string(d.Errors)) + + // nil Go Object/Slice map to empty JSON Object/Array for consistency + + d.Entity = new(entity.Entity) + err = d.marshal() + require.NoError(t, err) + + require.JSONEq(t, `{}`, string(d.Labels)) + require.JSONEq(t, `{}`, string(d.Fields)) + require.JSONEq(t, `[]`, string(d.Errors)) + }) + + t.Run("happy path - everything to marshal", func(t *testing.T) { + t.Parallel() + + d := &returnsEntity{ + Entity: &entity.Entity{ + Labels: someMap, + Fields: someMap, + Errors: someErrors, + }, + } + err := d.marshal() + require.NoError(t, err) + + require.JSONEq(t, someMapJSON, string(d.Labels)) + require.JSONEq(t, someMapJSON, string(d.Fields)) + require.JSONEq(t, someErrorsJSON, string(d.Errors)) + }) + + // NOTE: the error path for serialization is apparently unreachable. If you + // find a way to simulate a serialization error, consider raising awareness + // of such case(s) and add the corresponding tests here +} + +func TestReturnsEntity_unmarshal(t *testing.T) { + t.Parallel() + + t.Run("happy path - nothing to unmarshal", func(t *testing.T) { + t.Parallel() + + e := newReturnsEntity() + err := e.unmarshal() + require.NoError(t, err) + require.NotNil(t, e.Entity.Labels) + require.NotNil(t, e.Entity.Fields) + require.NotNil(t, e.Entity.Errors) + }) + + t.Run("happy path - everything to unmarshal", func(t *testing.T) { + t.Parallel() + + e := newReturnsEntity() + e.Labels = []byte(`{}`) + e.Fields = []byte(`{}`) + e.Errors = []byte(`[]`) + err := e.unmarshal() + require.NoError(t, err) + require.NotNil(t, e.Entity.Labels) + require.NotNil(t, e.Entity.Fields) + require.NotNil(t, e.Entity.Errors) + }) + + t.Run("fail to unmarshal", func(t *testing.T) { + t.Parallel() + + var jsonInvalid = []byte(`.`) + + e := newReturnsEntity() + e.Labels = jsonInvalid + err := e.unmarshal() + require.Error(t, err) + require.ErrorContains(t, err, "labels") + + e = newReturnsEntity() + e.Labels = nil + e.Fields = jsonInvalid + err = e.unmarshal() + require.Error(t, err) + require.ErrorContains(t, err, "fields") + + e = newReturnsEntity() + e.Fields = nil + e.Errors = jsonInvalid + err = e.unmarshal() + require.Error(t, err) + require.ErrorContains(t, err, "errors") + }) +} + +func TestReadEntity(t *testing.T) { + t.Parallel() + + // readonly, shared data for all subtests + expectedEntity := newEmptyEntity() + testdataJSON(t, `grpc-res-entity.json`, expectedEntity) + key, err := entity.ParseKey(expectedEntity.Key) + require.NoErrorf(t, err, "provided key: %#v", expectedEntity) + + t.Run("happy path - entity table, optimistic locking", func(t *testing.T) { + t.Parallel() + + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + x := expectReadEntity(t, mock, cloneEntity(expectedEntity)) + x(ctx, db) + }) + + t.Run("happy path - entity table, no optimistic locking", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + readReq := sqlEntityReadRequest{ // used to generate mock results + SQLTemplate: sqltemplate.New(sqltemplate.MySQL), + Key: new(entity.Key), + returnsEntitySet: newReturnsEntitySet(), + } + readReq.Entity.Entity = cloneEntity(expectedEntity) + results := newMockResults(t, mock, sqlEntityRead, readReq) + + // setup expectations + results.AddCurrentData() + mock.ExpectQuery(`select from entity where !resource_version update`). + WillReturnRows(results.Rows()) + + // execute and assert + e, err := readEntity(ctx, db, sqltemplate.MySQL, key, 0, false, true) + require.NoError(t, err) + require.Equal(t, expectedEntity, e.Entity) + }) + + t.Run("happy path - entity_history table", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + readReq := sqlEntityReadRequest{ // used to generate mock results + SQLTemplate: sqltemplate.New(sqltemplate.MySQL), + Key: new(entity.Key), + returnsEntitySet: newReturnsEntitySet(), + } + readReq.Entity.Entity = cloneEntity(expectedEntity) + results := newMockResults(t, mock, sqlEntityRead, readReq) + + // setup expectations + results.AddCurrentData() + mock.ExpectQuery(`select from entity_history where resource_version !update`). + WillReturnRows(results.Rows()) + + // execute and assert + e, err := readEntity(ctx, db, sqltemplate.MySQL, key, + expectedEntity.ResourceVersion, false, false) + require.NoError(t, err) + require.Equal(t, expectedEntity, e.Entity) + }) + + t.Run("entity table, optimistic locking failed", func(t *testing.T) { + t.Parallel() + + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + x := expectReadEntity(t, mock, nil) + x(ctx, db) + }) + + t.Run("entity_history table, entity not found", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + readReq := sqlEntityReadRequest{ // used to generate mock results + SQLTemplate: sqltemplate.New(sqltemplate.MySQL), + Key: new(entity.Key), + returnsEntitySet: newReturnsEntitySet(), + } + results := newMockResults(t, mock, sqlEntityRead, readReq) + + // setup expectations + mock.ExpectQuery(`select from entity_history where resource_version !update`). + WillReturnRows(results.Rows()) + + // execute and assert + e, err := readEntity(ctx, db, sqltemplate.MySQL, key, + expectedEntity.ResourceVersion, false, false) + require.Nil(t, e) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("entity_history table, entity was deleted = not found", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + readReq := sqlEntityReadRequest{ // used to generate mock results + SQLTemplate: sqltemplate.New(sqltemplate.MySQL), + Key: new(entity.Key), + returnsEntitySet: newReturnsEntitySet(), + } + readReq.Entity.Entity = cloneEntity(expectedEntity) + readReq.Entity.Entity.Action = entity.Entity_DELETED + results := newMockResults(t, mock, sqlEntityRead, readReq) + + // setup expectations + results.AddCurrentData() + mock.ExpectQuery(`select from entity_history where resource_version !update`). + WillReturnRows(results.Rows()) + + // execute and assert + e, err := readEntity(ctx, db, sqltemplate.MySQL, key, + expectedEntity.ResourceVersion, false, false) + require.Nil(t, e) + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + }) +} + +// expectReadEntity arranges test expectations so that it's easier to reuse +// across tests that need to call `readEntity`. If you provide a non-nil +// *entity.Entity, that will be returned by `readEntity`. If it's nil, then +// `readEntity` will return ErrOptimisticLockingFailed. It returns the function +// to execute the actual test and assert the expectations that were set. +func expectReadEntity(t *testing.T, mock sqlmock.Sqlmock, e *entity.Entity) func(ctx context.Context, db db.DB) { + t.Helper() + + // test declarations + readReq := sqlEntityReadRequest{ // used to generate mock results + SQLTemplate: sqltemplate.New(sqltemplate.MySQL), + Key: new(entity.Key), + returnsEntitySet: newReturnsEntitySet(), + } + results := newMockResults(t, mock, sqlEntityRead, readReq) + if e != nil { + readReq.Entity.Entity = cloneEntity(e) + } + + // setup expectations + results.AddCurrentData() + mock.ExpectQuery(`select from entity where !resource_version update`). + WillReturnRows(results.Rows()) + + // execute and assert + if e != nil { + return func(ctx context.Context, db db.DB) { + ent, err := readEntity(ctx, db, sqltemplate.MySQL, readReq.Key, + e.ResourceVersion, true, true) + require.NoError(t, err) + require.Equal(t, e, ent.Entity) + } + } + + return func(ctx context.Context, db db.DB) { + ent, err := readEntity(ctx, db, sqltemplate.MySQL, readReq.Key, 1, true, + true) + require.Nil(t, ent) + require.Error(t, err) + require.ErrorIs(t, err, ErrOptimisticLockingFailed) + } +} + +func TestKindVersionAtomicInc(t *testing.T) { + t.Parallel() + + t.Run("happy path - row locked", func(t *testing.T) { + t.Parallel() + + // test declarations + const curVersion int64 = 1 + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + + // setup expectations + mock.ExpectQuery(`select resource_version from kind_version where group resource update`). + WillReturnRows(mock.NewRows([]string{"resource_version"}).AddRow(curVersion)) + mock.ExpectExec("update kind_version set resource_version updated_at where group resource"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // execute and assert + gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") + require.NoError(t, err) + require.Equal(t, curVersion+1, gotVersion) + }) + + t.Run("happy path - row created", func(t *testing.T) { + t.Parallel() + + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + x := expectKindVersionAtomicInc(t, mock, false) + x(ctx, db) + }) + + t.Run("fail to create row", func(t *testing.T) { + t.Parallel() + + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBMatchWords(t) + x := expectKindVersionAtomicInc(t, mock, true) + x(ctx, db) + }) +} + +// expectKindVersionAtomicInc arranges test expectations so that it's easier to +// reuse across tests that need to call `kindVersionAtomicInc`. If you the test +// shuld fail, it will do so with `errTest`, and it will return resource version +// 1 otherwise. It returns the function to execute the actual test and assert +// the expectations that were set. +func expectKindVersionAtomicInc(t *testing.T, mock sqlmock.Sqlmock, shouldFail bool) func(ctx context.Context, db db.DB) { + t.Helper() + + // setup expectations + mock.ExpectQuery(`select resource_version from kind_version where group resource update`). + WillReturnRows(mock.NewRows([]string{"resource_version"})) + call := mock.ExpectExec("insert kind_version resource_version") + + // execute and assert + if shouldFail { + call.WillReturnError(errTest) + + return func(ctx context.Context, db db.DB) { + gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") + require.Zero(t, gotVersion) + require.Error(t, err) + require.ErrorIs(t, err, errTest) + } + } + call.WillReturnResult(sqlmock.NewResult(0, 1)) + + return func(ctx context.Context, db db.DB) { + gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") + require.NoError(t, err) + require.Equal(t, int64(1), gotVersion) + } +} + +func TestMustTemplate(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + mustTemplate("non existent file") + }) +} + +// Debug provides greater detail about the SQL error. It is defined on the same +// struct but on a test file so that the intention that its results should not +// be used in runtime code is very clear. The results could include PII or +// otherwise regulated information, hence this method is only available in +// tests, so that it can be used in local debugging only. Note that the error +// information may still be available through other means, like using the +// "reflect" package, so care must be taken not to ever expose these information +// in production. +func (e SQLError) Debug() string { + scanDestStr := "(none)" + if len(e.ScanDest) > 0 { + format := "[%T" + strings.Repeat(", %T", len(e.ScanDest)-1) + "]" + scanDestStr = fmt.Sprintf(format, e.ScanDest...) + } + + return fmt.Sprintf("%s: %s: %v\n\tArguments (%d): %#v\n\tReturn Value "+ + "Types (%d): %s\n\tExecuted Query: %s\n\tRaw SQL Template Output: %s", + e.TemplateName, e.CallType, e.Err, len(e.arguments), e.arguments, + len(e.ScanDest), scanDestStr, e.Query, e.RawQuery) +} diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index 19465f00082..b2046dba367 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -535,19 +535,6 @@ type SortBy struct { Direction Direction } -func parseAllSortBy(s []string) ([]SortBy, error) { - ret := make([]SortBy, len(s)) - for i, v := range s { - ss, err := ParseSortBy(v) - if err != nil { - return nil, fmt.Errorf("parse #%d-eth sort item: %w", i, err) - } - ret[i] = ss - } - - return ret, nil -} - func ParseSortBy(sort string) (SortBy, error) { sortBy := SortBy{ Field: "guid", diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server_test.go b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go index 4cfb08a866e..5d766f62524 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server_test.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go @@ -1,65 +1,28 @@ package sqlstash import ( - "context" "testing" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/stretchr/testify/require" - oldDB "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/store/entity" - "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" - "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" ) -func TestMain(m *testing.M) { - testsuite.Run(m) -} - func TestIsHealthy(t *testing.T) { - s := setUpTestServer(t) + t.Parallel() - _, err := s.IsHealthy(context.Background(), &entity.HealthCheckRequest{}) - require.NoError(t, err) -} - -func setUpTestServer(t *testing.T) entity.EntityStoreServer { - if testing.Short() { - t.Skip("skipping integration test in short mode") + // test declarations + ctx := testutil.NewDefaultTestContext(t) + db, mock := newMockDBNopSQL(t) + s := &sqlEntityServer{ + sqlDB: db, } - sqlStore, cfg := oldDB.InitTestDBWithCfg(t) - entityDB, err := dbimpl.ProvideEntityDB( - sqlStore, - cfg, - featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorage), - nil, - ) - require.NoError(t, err) + // setup expectations + mock.ExpectPing() - traceConfig, err := tracing.ParseTracingConfig(cfg) + // execute and assert + _, err := s.IsHealthy(ctx, new(entity.HealthCheckRequest)) require.NoError(t, err) - tracer, err := tracing.ProvideService(traceConfig) - require.NoError(t, err) - - s, err := ProvideSQLEntityServer(entityDB, tracer) - require.NoError(t, err) - return s } - -// TODO: remove all the following once the Proposal 1 for Consistent Resource -// Version is finished. -var ( - _ = parseAllSortBy - _ = countTrue - _ = query[any] - _ = sqlEntityHistory - _ = sqlEntityRefFind - _ = sqlKindVersionGet - _ = sqlEntityRefFindRequest{} - _ = sqlKindVersionGetRequest{} - _ = sqlEntityHistoryRequest{} - _ = sqlEntityHistoryListRequest{} -) diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/args.go b/pkg/services/store/entity/sqlstash/sqltemplate/args.go index f56594cd1aa..eac4452ea37 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/args.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/args.go @@ -16,7 +16,7 @@ var ( // embedding or with a named struct field if its Arg method would clash with // another struct field. type Args struct { - d Dialect + d interface{ ArgPlaceholder(argNum int) string } values []any } @@ -69,6 +69,10 @@ func (a *Args) ArgList(slice reflect.Value) (string, error) { return b.String(), nil } +func (a *Args) Reset() { + a.values = nil +} + func (a *Args) GetArgs() []any { return a.values } @@ -77,4 +81,5 @@ type ArgsIface interface { Arg(x any) string ArgList(slice reflect.Value) (string, error) GetArgs() []any + Reset() } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/args_test.go b/pkg/services/store/entity/sqlstash/sqltemplate/args_test.go index 3d4b578c28c..5399db21ae1 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/args_test.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/args_test.go @@ -1,6 +1,10 @@ package sqltemplate -import "testing" +import ( + "errors" + "reflect" + "testing" +) func TestArgs_Arg(t *testing.T) { t.Parallel() @@ -30,3 +34,68 @@ func TestArgs_Arg(t *testing.T) { } } } + +func TestArg_ArgList(t *testing.T) { + t.Parallel() + + testCases := []struct { + input reflect.Value + added []any + output string + err error + }{ + {err: ErrInvalidArgList}, + {input: reflect.ValueOf(1), err: ErrInvalidArgList}, + {input: reflect.ValueOf(nil), err: ErrInvalidArgList}, + {input: reflect.ValueOf(any(nil)), err: ErrInvalidArgList}, + {input: reflect.ValueOf("asd"), err: ErrInvalidArgList}, + {input: reflect.ValueOf([]any{})}, + + { + input: reflect.ValueOf([]any{true}), + added: []any{true}, + output: "?", + }, + + { + input: reflect.ValueOf([]any{1, true}), + added: []any{1, true}, + output: "?, ?", + }, + + { + input: reflect.ValueOf([]any{1, "asd", true}), + added: []any{1, "asd", true}, + output: "?, ?, ?", + }, + } + + var a Args + a.d = argFmtSQL92 + for i, tc := range testCases { + a.Reset() + + gotOutput, gotErr := a.ArgList(tc.input) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("[test #%d] Unexpected error. Expected: %v, actual: %v", + i, gotErr, tc.err) + } + + if tc.output != gotOutput { + t.Fatalf("[test #%d] Unexpected output. Expected: %v, actual: %v", + i, gotOutput, tc.output) + } + + if len(tc.added) != len(a.values) { + t.Fatalf("[test #%d] Unexpected added items.\n\tExpected: %#v\n\t"+ + "Actual: %#v", i, tc.added, a.values) + } + + for j := range tc.added { + if !reflect.DeepEqual(tc.added[j], a.values[j]) { + t.Fatalf("[test #%d] Unexpected %d-eth item.\n\tExpected:"+ + " %#v\n\tActual: %#v", i, j, tc.added[j], a.values[j]) + } + } + } +} diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/dialect.go b/pkg/services/store/entity/sqlstash/sqltemplate/dialect.go index e61e9840abb..2824d783059 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/dialect.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/dialect.go @@ -17,15 +17,21 @@ var ( // embedded for ease of use, or with a named struct field if any of its methods // would clash with other struct field names. type Dialect interface { + // Name identifies the Dialect. Note that a Dialect may be common to more + // than one DBMS (e.g. "postgres" is common to PostgreSQL and to + // CockroachDB), while we can maintain different Dialects for the same DBMS + // but different versions (e.g. "mysql5" and "mysql8"). + Name() string + // Ident returns the given string quoted in a way that is suitable to be // used as an identifier. Database names, schema names, table names, column // names are all examples of identifiers. Ident(string) (string, error) // ArgPlaceholder returns a safe argument suitable to be used in a SQL - // prepared statement for the argNum-eth argument passed in execution. The - // SQL92 Standard specifies the question mark ('?') should be used in all - // cases, but some implementations differ. + // prepared statement for the argNum-eth argument passed in execution + // (starting at 1). The SQL92 Standard specifies the question mark ('?') + // should be used in all cases, but some implementations differ. ArgPlaceholder(argNum int) string // SelectFor parses and returns the given row-locking clause for a SELECT @@ -75,12 +81,9 @@ const ( SelectForUpdateSkipLocked RowLockingClause = "UPDATE SKIP LOCKED" ) -// rowLockingClauseAll aids implementations that either support all the -// row-locking clause options or none. If it's true, it returns the clause, -// otherwise it returns an empty string. -type rowLockingClauseAll bool +type rowLockingClauseMap map[RowLockingClause]RowLockingClause -func (rlc rowLockingClauseAll) SelectFor(s ...string) (string, error) { +func (rlc rowLockingClauseMap) SelectFor(s ...string) (string, error) { // all implementations should err on invalid input, otherwise we would just // be hiding the error until we change the dialect o, err := ParseRowLockingClause(s...) @@ -88,11 +91,21 @@ func (rlc rowLockingClauseAll) SelectFor(s ...string) (string, error) { return "", err } - if !rlc { - return "", nil + var ret string + if len(rlc) > 0 { + ret = "FOR " + string(rlc[o]) } - return "FOR " + string(o), nil + return ret, nil +} + +var rowLockingClauseAll = rowLockingClauseMap{ + SelectForShare: SelectForShare, + SelectForShareNoWait: SelectForShareNoWait, + SelectForShareSkipLocked: SelectForShareSkipLocked, + SelectForUpdate: SelectForUpdate, + SelectForUpdateNoWait: SelectForUpdateNoWait, + SelectForUpdateSkipLocked: SelectForUpdateSkipLocked, } // standardIdent provides standard SQL escaping of identifiers. @@ -119,3 +132,9 @@ var ( return "$" + strconv.Itoa(argNum) }) ) + +type name string + +func (n name) Name() string { + return string(n) +} diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql.go b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql.go index ce2e6e7f19b..705c8c9eca4 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql.go @@ -1,19 +1,21 @@ package sqltemplate -// MySQL is an implementation of Dialect for the MySQL DMBS. It relies on having -// ANSI_QUOTES SQL Mode enabled. For more information about ANSI_QUOTES and SQL -// Modes see: +// MySQL is the default implementation of Dialect for the MySQL DMBS, currently +// supporting MySQL-8.x. It relies on having ANSI_QUOTES SQL Mode enabled. For +// more information about ANSI_QUOTES and SQL Modes see: // // https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes var MySQL = mysql{ - rowLockingClauseAll: true, + rowLockingClauseMap: rowLockingClauseAll, argPlaceholderFunc: argFmtSQL92, + name: "mysql", } var _ Dialect = MySQL type mysql struct { standardIdent - rowLockingClauseAll + rowLockingClauseMap argPlaceholderFunc + name } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql.go b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql.go index a9a8b9e1c73..39e8603ae0b 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql.go @@ -7,8 +7,9 @@ import ( // PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS. var PostgreSQL = postgresql{ - rowLockingClauseAll: true, + rowLockingClauseMap: rowLockingClauseAll, argPlaceholderFunc: argFmtPositional, + name: "postgres", } var _ Dialect = PostgreSQL @@ -20,8 +21,9 @@ var ( type postgresql struct { standardIdent - rowLockingClauseAll + rowLockingClauseMap argPlaceholderFunc + name } func (p postgresql) Ident(s string) (string, error) { diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite.go b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite.go index 0017ef45086..8a41a8f2c4d 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite.go @@ -2,8 +2,8 @@ package sqltemplate // SQLite is an implementation of Dialect for the SQLite DMBS. var SQLite = sqlite{ - rowLockingClauseAll: false, - argPlaceholderFunc: argFmtSQL92, + argPlaceholderFunc: argFmtSQL92, + name: "sqlite", } var _ Dialect = SQLite @@ -12,6 +12,7 @@ type sqlite struct { // See: // https://www.sqlite.org/lang_keywords.html standardIdent - rowLockingClauseAll + rowLockingClauseMap argPlaceholderFunc + name } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_test.go b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_test.go index 4f775ab77aa..987170fd6f3 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/dialect_test.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/dialect_test.go @@ -73,7 +73,7 @@ func TestParseRowLockingClause(t *testing.T) { } } -func TestRowLockingClauseAll_SelectFor(t *testing.T) { +func TestRowLockingClauseMap_SelectFor(t *testing.T) { t.Parallel() splitSpace := func(s string) []string { @@ -95,21 +95,22 @@ func TestRowLockingClauseAll_SelectFor(t *testing.T) { }, } + var nilRLC rowLockingClauseMap for i, tc := range testCases { - gotOutput, gotErr := rowLockingClauseAll(true).SelectFor(tc.input...) + gotOutput, gotErr := nilRLC.SelectFor(tc.input...) if !errors.Is(gotErr, tc.err) { - t.Fatalf("[true] unexpected error %v in test case %d", gotErr, i) - } - if gotOutput != string(tc.output) { - t.Fatalf("[true] unexpected error %v in test case %d", gotErr, i) - } - - gotOutput, gotErr = rowLockingClauseAll(false).SelectFor(tc.input...) - if !errors.Is(gotErr, tc.err) { - t.Fatalf("[false] unexpected error %v in test case %d", gotErr, i) + t.Fatalf("[nil] unexpected error %v in test case %d", gotErr, i) } if gotOutput != "" { - t.Fatalf("[false] unexpected error %v in test case %d", gotErr, i) + t.Fatalf("[nil] unexpected output %v in test case %d", gotOutput, i) + } + + gotOutput, gotErr = rowLockingClauseAll.SelectFor(tc.input...) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("[all] unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != string(tc.output) { + t.Fatalf("[all] unexpected output %v in test case %d", gotOutput, i) } } } @@ -141,3 +142,43 @@ func TestStandardIdent_Ident(t *testing.T) { } } } + +func TestArgPlaceholderFunc(t *testing.T) { + t.Parallel() + + testCases := []struct { + input int + valuePositional string + }{ + { + input: 1, + valuePositional: "$1", + }, + { + input: 16, + valuePositional: "$16", + }, + } + + for i, tc := range testCases { + got := argFmtSQL92(tc.input) + if got != "?" { + t.Fatalf("[argFmtSQL92] unexpected value %q in test case %d", got, i) + } + + got = argFmtPositional(tc.input) + if got != tc.valuePositional { + t.Fatalf("[argFmtPositional] unexpected value %q in test case %d", got, i) + } + } +} + +func TestName_Name(t *testing.T) { + t.Parallel() + + const v = "some dialect name" + n := name(v) + if n.Name() != v { + t.Fatalf("unexpected dialect name %q", n.Name()) + } +} diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/into.go b/pkg/services/store/entity/sqlstash/sqltemplate/into.go index fa5af58aa51..2896f89481c 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/into.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/into.go @@ -6,7 +6,8 @@ import ( ) type ScanDest struct { - values []any + values []any + colNames []string } func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) { @@ -15,15 +16,26 @@ func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) { } i.values = append(i.values, v.Addr().Interface()) + i.colNames = append(i.colNames, colName) return colName, nil } +func (i *ScanDest) Reset() { + i.values = nil +} + func (i *ScanDest) GetScanDest() []any { return i.values } +func (i *ScanDest) GetColNames() []string { + return i.colNames +} + type ScanDestIface interface { Into(v reflect.Value, colName string) (string, error) GetScanDest() []any + GetColNames() []string + Reset() } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/into_test.go b/pkg/services/store/entity/sqlstash/sqltemplate/into_test.go index 697d0710247..b4b06767f7c 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/into_test.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/into_test.go @@ -2,6 +2,7 @@ package sqltemplate import ( "reflect" + "slices" "testing" ) @@ -22,17 +23,29 @@ func TestScanDest_Into(t *testing.T) { }{} dataVal := reflect.ValueOf(&data).Elem() - colName, err = d.Into(dataVal.FieldByName("X"), "some int") + expectedColNames := []string{"some int", "and a byte"} + + colName, err = d.Into(dataVal.FieldByName("X"), expectedColNames[0]) v := d.GetScanDest() - if err != nil || colName != "some int" || len(v) != 1 || v[0] != &data.X { + if err != nil || colName != expectedColNames[0] || len(v) != 1 || v[0] != &data.X { t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", colName, err, d) } - colName, err = d.Into(dataVal.FieldByName("Y"), "some byte") + colName, err = d.Into(dataVal.FieldByName("Y"), expectedColNames[1]) v = d.GetScanDest() - if err != nil || colName != "some byte" || len(v) != 2 || v[1] != &data.Y { + if err != nil || colName != expectedColNames[1] || len(v) != 2 || v[1] != &data.Y { t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", colName, err, d) } + + if gotColNames := d.GetColNames(); !slices.Equal(expectedColNames, gotColNames) { + t.Fatalf("unexpected column names: %v", gotColNames) + } + + d.Reset() + v = d.GetScanDest() + if len(v) != 0 { + t.Fatalf("unexpected values after reset: %v", v) + } } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/mocks/SQLTemplateIface.go b/pkg/services/store/entity/sqlstash/sqltemplate/mocks/SQLTemplateIface.go new file mode 100644 index 00000000000..d41f66f97c3 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/sqltemplate/mocks/SQLTemplateIface.go @@ -0,0 +1,664 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + reflect "reflect" + + mock "github.com/stretchr/testify/mock" + + sqltemplate "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" +) + +// SQLTemplateIface is an autogenerated mock type for the SQLTemplateIface type +type SQLTemplateIface struct { + mock.Mock +} + +type SQLTemplateIface_Expecter struct { + mock *mock.Mock +} + +func (_m *SQLTemplateIface) EXPECT() *SQLTemplateIface_Expecter { + return &SQLTemplateIface_Expecter{mock: &_m.Mock} +} + +// Arg provides a mock function with given fields: x +func (_m *SQLTemplateIface) Arg(x interface{}) string { + ret := _m.Called(x) + + if len(ret) == 0 { + panic("no return value specified for Arg") + } + + var r0 string + if rf, ok := ret.Get(0).(func(interface{}) string); ok { + r0 = rf(x) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_Arg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Arg' +type SQLTemplateIface_Arg_Call struct { + *mock.Call +} + +// Arg is a helper method to define mock.On call +// - x interface{} +func (_e *SQLTemplateIface_Expecter) Arg(x interface{}) *SQLTemplateIface_Arg_Call { + return &SQLTemplateIface_Arg_Call{Call: _e.mock.On("Arg", x)} +} + +func (_c *SQLTemplateIface_Arg_Call) Run(run func(x interface{})) *SQLTemplateIface_Arg_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(interface{})) + }) + return _c +} + +func (_c *SQLTemplateIface_Arg_Call) Return(_a0 string) *SQLTemplateIface_Arg_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Arg_Call) RunAndReturn(run func(interface{}) string) *SQLTemplateIface_Arg_Call { + _c.Call.Return(run) + return _c +} + +// ArgList provides a mock function with given fields: slice +func (_m *SQLTemplateIface) ArgList(slice reflect.Value) (string, error) { + ret := _m.Called(slice) + + if len(ret) == 0 { + panic("no return value specified for ArgList") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value) (string, error)); ok { + return rf(slice) + } + if rf, ok := ret.Get(0).(func(reflect.Value) string); ok { + r0 = rf(slice) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value) error); ok { + r1 = rf(slice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_ArgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgList' +type SQLTemplateIface_ArgList_Call struct { + *mock.Call +} + +// ArgList is a helper method to define mock.On call +// - slice reflect.Value +func (_e *SQLTemplateIface_Expecter) ArgList(slice interface{}) *SQLTemplateIface_ArgList_Call { + return &SQLTemplateIface_ArgList_Call{Call: _e.mock.On("ArgList", slice)} +} + +func (_c *SQLTemplateIface_ArgList_Call) Run(run func(slice reflect.Value)) *SQLTemplateIface_ArgList_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value)) + }) + return _c +} + +func (_c *SQLTemplateIface_ArgList_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_ArgList_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_ArgList_Call) RunAndReturn(run func(reflect.Value) (string, error)) *SQLTemplateIface_ArgList_Call { + _c.Call.Return(run) + return _c +} + +// ArgPlaceholder provides a mock function with given fields: argNum +func (_m *SQLTemplateIface) ArgPlaceholder(argNum int) string { + ret := _m.Called(argNum) + + if len(ret) == 0 { + panic("no return value specified for ArgPlaceholder") + } + + var r0 string + if rf, ok := ret.Get(0).(func(int) string); ok { + r0 = rf(argNum) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_ArgPlaceholder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgPlaceholder' +type SQLTemplateIface_ArgPlaceholder_Call struct { + *mock.Call +} + +// ArgPlaceholder is a helper method to define mock.On call +// - argNum int +func (_e *SQLTemplateIface_Expecter) ArgPlaceholder(argNum interface{}) *SQLTemplateIface_ArgPlaceholder_Call { + return &SQLTemplateIface_ArgPlaceholder_Call{Call: _e.mock.On("ArgPlaceholder", argNum)} +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) Run(run func(argNum int)) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) Return(_a0 string) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) RunAndReturn(run func(int) string) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Return(run) + return _c +} + +// GetArgs provides a mock function with given fields: +func (_m *SQLTemplateIface) GetArgs() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetArgs") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// SQLTemplateIface_GetArgs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetArgs' +type SQLTemplateIface_GetArgs_Call struct { + *mock.Call +} + +// GetArgs is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetArgs() *SQLTemplateIface_GetArgs_Call { + return &SQLTemplateIface_GetArgs_Call{Call: _e.mock.On("GetArgs")} +} + +func (_c *SQLTemplateIface_GetArgs_Call) Run(run func()) *SQLTemplateIface_GetArgs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetArgs_Call) Return(_a0 []interface{}) *SQLTemplateIface_GetArgs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetArgs_Call) RunAndReturn(run func() []interface{}) *SQLTemplateIface_GetArgs_Call { + _c.Call.Return(run) + return _c +} + +// GetColNames provides a mock function with given fields: +func (_m *SQLTemplateIface) GetColNames() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetColNames") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// SQLTemplateIface_GetColNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetColNames' +type SQLTemplateIface_GetColNames_Call struct { + *mock.Call +} + +// GetColNames is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetColNames() *SQLTemplateIface_GetColNames_Call { + return &SQLTemplateIface_GetColNames_Call{Call: _e.mock.On("GetColNames")} +} + +func (_c *SQLTemplateIface_GetColNames_Call) Run(run func()) *SQLTemplateIface_GetColNames_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetColNames_Call) Return(_a0 []string) *SQLTemplateIface_GetColNames_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetColNames_Call) RunAndReturn(run func() []string) *SQLTemplateIface_GetColNames_Call { + _c.Call.Return(run) + return _c +} + +// GetScanDest provides a mock function with given fields: +func (_m *SQLTemplateIface) GetScanDest() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetScanDest") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// SQLTemplateIface_GetScanDest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetScanDest' +type SQLTemplateIface_GetScanDest_Call struct { + *mock.Call +} + +// GetScanDest is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetScanDest() *SQLTemplateIface_GetScanDest_Call { + return &SQLTemplateIface_GetScanDest_Call{Call: _e.mock.On("GetScanDest")} +} + +func (_c *SQLTemplateIface_GetScanDest_Call) Run(run func()) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetScanDest_Call) Return(_a0 []interface{}) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetScanDest_Call) RunAndReturn(run func() []interface{}) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Return(run) + return _c +} + +// Ident provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) Ident(_a0 string) (string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Ident") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_Ident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ident' +type SQLTemplateIface_Ident_Call struct { + *mock.Call +} + +// Ident is a helper method to define mock.On call +// - _a0 string +func (_e *SQLTemplateIface_Expecter) Ident(_a0 interface{}) *SQLTemplateIface_Ident_Call { + return &SQLTemplateIface_Ident_Call{Call: _e.mock.On("Ident", _a0)} +} + +func (_c *SQLTemplateIface_Ident_Call) Run(run func(_a0 string)) *SQLTemplateIface_Ident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *SQLTemplateIface_Ident_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_Ident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_Ident_Call) RunAndReturn(run func(string) (string, error)) *SQLTemplateIface_Ident_Call { + _c.Call.Return(run) + return _c +} + +// Into provides a mock function with given fields: v, colName +func (_m *SQLTemplateIface) Into(v reflect.Value, colName string) (string, error) { + ret := _m.Called(v, colName) + + if len(ret) == 0 { + panic("no return value specified for Into") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value, string) (string, error)); ok { + return rf(v, colName) + } + if rf, ok := ret.Get(0).(func(reflect.Value, string) string); ok { + r0 = rf(v, colName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value, string) error); ok { + r1 = rf(v, colName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_Into_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Into' +type SQLTemplateIface_Into_Call struct { + *mock.Call +} + +// Into is a helper method to define mock.On call +// - v reflect.Value +// - colName string +func (_e *SQLTemplateIface_Expecter) Into(v interface{}, colName interface{}) *SQLTemplateIface_Into_Call { + return &SQLTemplateIface_Into_Call{Call: _e.mock.On("Into", v, colName)} +} + +func (_c *SQLTemplateIface_Into_Call) Run(run func(v reflect.Value, colName string)) *SQLTemplateIface_Into_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value), args[1].(string)) + }) + return _c +} + +func (_c *SQLTemplateIface_Into_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_Into_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_Into_Call) RunAndReturn(run func(reflect.Value, string) (string, error)) *SQLTemplateIface_Into_Call { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with given fields: +func (_m *SQLTemplateIface) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type SQLTemplateIface_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Name() *SQLTemplateIface_Name_Call { + return &SQLTemplateIface_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *SQLTemplateIface_Name_Call) Run(run func()) *SQLTemplateIface_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Name_Call) Return(_a0 string) *SQLTemplateIface_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Name_Call) RunAndReturn(run func() string) *SQLTemplateIface_Name_Call { + _c.Call.Return(run) + return _c +} + +// Reset provides a mock function with given fields: +func (_m *SQLTemplateIface) Reset() { + _m.Called() +} + +// SQLTemplateIface_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type SQLTemplateIface_Reset_Call struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Reset() *SQLTemplateIface_Reset_Call { + return &SQLTemplateIface_Reset_Call{Call: _e.mock.On("Reset")} +} + +func (_c *SQLTemplateIface_Reset_Call) Run(run func()) *SQLTemplateIface_Reset_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Reset_Call) Return() *SQLTemplateIface_Reset_Call { + _c.Call.Return() + return _c +} + +func (_c *SQLTemplateIface_Reset_Call) RunAndReturn(run func()) *SQLTemplateIface_Reset_Call { + _c.Call.Return(run) + return _c +} + +// SelectFor provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) SelectFor(_a0 ...string) (string, error) { + _va := make([]interface{}, len(_a0)) + for _i := range _a0 { + _va[_i] = _a0[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SelectFor") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(...string) (string, error)); ok { + return rf(_a0...) + } + if rf, ok := ret.Get(0).(func(...string) string); ok { + r0 = rf(_a0...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(...string) error); ok { + r1 = rf(_a0...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_SelectFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectFor' +type SQLTemplateIface_SelectFor_Call struct { + *mock.Call +} + +// SelectFor is a helper method to define mock.On call +// - _a0 ...string +func (_e *SQLTemplateIface_Expecter) SelectFor(_a0 ...interface{}) *SQLTemplateIface_SelectFor_Call { + return &SQLTemplateIface_SelectFor_Call{Call: _e.mock.On("SelectFor", + append([]interface{}{}, _a0...)...)} +} + +func (_c *SQLTemplateIface_SelectFor_Call) Run(run func(_a0 ...string)) *SQLTemplateIface_SelectFor_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *SQLTemplateIface_SelectFor_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_SelectFor_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_SelectFor_Call) RunAndReturn(run func(...string) (string, error)) *SQLTemplateIface_SelectFor_Call { + _c.Call.Return(run) + return _c +} + +// SetDialect provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) SetDialect(_a0 sqltemplate.Dialect) { + _m.Called(_a0) +} + +// SQLTemplateIface_SetDialect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDialect' +type SQLTemplateIface_SetDialect_Call struct { + *mock.Call +} + +// SetDialect is a helper method to define mock.On call +// - _a0 sqltemplate.Dialect +func (_e *SQLTemplateIface_Expecter) SetDialect(_a0 interface{}) *SQLTemplateIface_SetDialect_Call { + return &SQLTemplateIface_SetDialect_Call{Call: _e.mock.On("SetDialect", _a0)} +} + +func (_c *SQLTemplateIface_SetDialect_Call) Run(run func(_a0 sqltemplate.Dialect)) *SQLTemplateIface_SetDialect_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(sqltemplate.Dialect)) + }) + return _c +} + +func (_c *SQLTemplateIface_SetDialect_Call) Return() *SQLTemplateIface_SetDialect_Call { + _c.Call.Return() + return _c +} + +func (_c *SQLTemplateIface_SetDialect_Call) RunAndReturn(run func(sqltemplate.Dialect)) *SQLTemplateIface_SetDialect_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: +func (_m *SQLTemplateIface) Validate() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SQLTemplateIface_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type SQLTemplateIface_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Validate() *SQLTemplateIface_Validate_Call { + return &SQLTemplateIface_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *SQLTemplateIface_Validate_Call) Run(run func()) *SQLTemplateIface_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Validate_Call) Return(_a0 error) *SQLTemplateIface_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Validate_Call) RunAndReturn(run func() error) *SQLTemplateIface_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewSQLTemplateIface creates a new instance of SQLTemplateIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSQLTemplateIface(t interface { + mock.TestingT + Cleanup(func()) +}) *SQLTemplateIface { + mock := &SQLTemplateIface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/mocks/WithResults.go b/pkg/services/store/entity/sqlstash/sqltemplate/mocks/WithResults.go new file mode 100644 index 00000000000..7c98a0c3433 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/sqltemplate/mocks/WithResults.go @@ -0,0 +1,719 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + reflect "reflect" + + mock "github.com/stretchr/testify/mock" + + sqltemplate "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" +) + +// WithResults is an autogenerated mock type for the WithResults type +type WithResults[T interface{}] struct { + mock.Mock +} + +type WithResults_Expecter[T interface{}] struct { + mock *mock.Mock +} + +func (_m *WithResults[T]) EXPECT() *WithResults_Expecter[T] { + return &WithResults_Expecter[T]{mock: &_m.Mock} +} + +// Arg provides a mock function with given fields: x +func (_m *WithResults[T]) Arg(x interface{}) string { + ret := _m.Called(x) + + if len(ret) == 0 { + panic("no return value specified for Arg") + } + + var r0 string + if rf, ok := ret.Get(0).(func(interface{}) string); ok { + r0 = rf(x) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_Arg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Arg' +type WithResults_Arg_Call[T interface{}] struct { + *mock.Call +} + +// Arg is a helper method to define mock.On call +// - x interface{} +func (_e *WithResults_Expecter[T]) Arg(x interface{}) *WithResults_Arg_Call[T] { + return &WithResults_Arg_Call[T]{Call: _e.mock.On("Arg", x)} +} + +func (_c *WithResults_Arg_Call[T]) Run(run func(x interface{})) *WithResults_Arg_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(interface{})) + }) + return _c +} + +func (_c *WithResults_Arg_Call[T]) Return(_a0 string) *WithResults_Arg_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Arg_Call[T]) RunAndReturn(run func(interface{}) string) *WithResults_Arg_Call[T] { + _c.Call.Return(run) + return _c +} + +// ArgList provides a mock function with given fields: slice +func (_m *WithResults[T]) ArgList(slice reflect.Value) (string, error) { + ret := _m.Called(slice) + + if len(ret) == 0 { + panic("no return value specified for ArgList") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value) (string, error)); ok { + return rf(slice) + } + if rf, ok := ret.Get(0).(func(reflect.Value) string); ok { + r0 = rf(slice) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value) error); ok { + r1 = rf(slice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_ArgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgList' +type WithResults_ArgList_Call[T interface{}] struct { + *mock.Call +} + +// ArgList is a helper method to define mock.On call +// - slice reflect.Value +func (_e *WithResults_Expecter[T]) ArgList(slice interface{}) *WithResults_ArgList_Call[T] { + return &WithResults_ArgList_Call[T]{Call: _e.mock.On("ArgList", slice)} +} + +func (_c *WithResults_ArgList_Call[T]) Run(run func(slice reflect.Value)) *WithResults_ArgList_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value)) + }) + return _c +} + +func (_c *WithResults_ArgList_Call[T]) Return(_a0 string, _a1 error) *WithResults_ArgList_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_ArgList_Call[T]) RunAndReturn(run func(reflect.Value) (string, error)) *WithResults_ArgList_Call[T] { + _c.Call.Return(run) + return _c +} + +// ArgPlaceholder provides a mock function with given fields: argNum +func (_m *WithResults[T]) ArgPlaceholder(argNum int) string { + ret := _m.Called(argNum) + + if len(ret) == 0 { + panic("no return value specified for ArgPlaceholder") + } + + var r0 string + if rf, ok := ret.Get(0).(func(int) string); ok { + r0 = rf(argNum) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_ArgPlaceholder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgPlaceholder' +type WithResults_ArgPlaceholder_Call[T interface{}] struct { + *mock.Call +} + +// ArgPlaceholder is a helper method to define mock.On call +// - argNum int +func (_e *WithResults_Expecter[T]) ArgPlaceholder(argNum interface{}) *WithResults_ArgPlaceholder_Call[T] { + return &WithResults_ArgPlaceholder_Call[T]{Call: _e.mock.On("ArgPlaceholder", argNum)} +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) Run(run func(argNum int)) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) Return(_a0 string) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) RunAndReturn(run func(int) string) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetArgs provides a mock function with given fields: +func (_m *WithResults[T]) GetArgs() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetArgs") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// WithResults_GetArgs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetArgs' +type WithResults_GetArgs_Call[T interface{}] struct { + *mock.Call +} + +// GetArgs is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetArgs() *WithResults_GetArgs_Call[T] { + return &WithResults_GetArgs_Call[T]{Call: _e.mock.On("GetArgs")} +} + +func (_c *WithResults_GetArgs_Call[T]) Run(run func()) *WithResults_GetArgs_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetArgs_Call[T]) Return(_a0 []interface{}) *WithResults_GetArgs_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetArgs_Call[T]) RunAndReturn(run func() []interface{}) *WithResults_GetArgs_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetColNames provides a mock function with given fields: +func (_m *WithResults[T]) GetColNames() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetColNames") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// WithResults_GetColNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetColNames' +type WithResults_GetColNames_Call[T interface{}] struct { + *mock.Call +} + +// GetColNames is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetColNames() *WithResults_GetColNames_Call[T] { + return &WithResults_GetColNames_Call[T]{Call: _e.mock.On("GetColNames")} +} + +func (_c *WithResults_GetColNames_Call[T]) Run(run func()) *WithResults_GetColNames_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetColNames_Call[T]) Return(_a0 []string) *WithResults_GetColNames_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetColNames_Call[T]) RunAndReturn(run func() []string) *WithResults_GetColNames_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetScanDest provides a mock function with given fields: +func (_m *WithResults[T]) GetScanDest() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetScanDest") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// WithResults_GetScanDest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetScanDest' +type WithResults_GetScanDest_Call[T interface{}] struct { + *mock.Call +} + +// GetScanDest is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetScanDest() *WithResults_GetScanDest_Call[T] { + return &WithResults_GetScanDest_Call[T]{Call: _e.mock.On("GetScanDest")} +} + +func (_c *WithResults_GetScanDest_Call[T]) Run(run func()) *WithResults_GetScanDest_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetScanDest_Call[T]) Return(_a0 []interface{}) *WithResults_GetScanDest_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetScanDest_Call[T]) RunAndReturn(run func() []interface{}) *WithResults_GetScanDest_Call[T] { + _c.Call.Return(run) + return _c +} + +// Ident provides a mock function with given fields: _a0 +func (_m *WithResults[T]) Ident(_a0 string) (string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Ident") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Ident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ident' +type WithResults_Ident_Call[T interface{}] struct { + *mock.Call +} + +// Ident is a helper method to define mock.On call +// - _a0 string +func (_e *WithResults_Expecter[T]) Ident(_a0 interface{}) *WithResults_Ident_Call[T] { + return &WithResults_Ident_Call[T]{Call: _e.mock.On("Ident", _a0)} +} + +func (_c *WithResults_Ident_Call[T]) Run(run func(_a0 string)) *WithResults_Ident_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *WithResults_Ident_Call[T]) Return(_a0 string, _a1 error) *WithResults_Ident_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Ident_Call[T]) RunAndReturn(run func(string) (string, error)) *WithResults_Ident_Call[T] { + _c.Call.Return(run) + return _c +} + +// Into provides a mock function with given fields: v, colName +func (_m *WithResults[T]) Into(v reflect.Value, colName string) (string, error) { + ret := _m.Called(v, colName) + + if len(ret) == 0 { + panic("no return value specified for Into") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value, string) (string, error)); ok { + return rf(v, colName) + } + if rf, ok := ret.Get(0).(func(reflect.Value, string) string); ok { + r0 = rf(v, colName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value, string) error); ok { + r1 = rf(v, colName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Into_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Into' +type WithResults_Into_Call[T interface{}] struct { + *mock.Call +} + +// Into is a helper method to define mock.On call +// - v reflect.Value +// - colName string +func (_e *WithResults_Expecter[T]) Into(v interface{}, colName interface{}) *WithResults_Into_Call[T] { + return &WithResults_Into_Call[T]{Call: _e.mock.On("Into", v, colName)} +} + +func (_c *WithResults_Into_Call[T]) Run(run func(v reflect.Value, colName string)) *WithResults_Into_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value), args[1].(string)) + }) + return _c +} + +func (_c *WithResults_Into_Call[T]) Return(_a0 string, _a1 error) *WithResults_Into_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Into_Call[T]) RunAndReturn(run func(reflect.Value, string) (string, error)) *WithResults_Into_Call[T] { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with given fields: +func (_m *WithResults[T]) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type WithResults_Name_Call[T interface{}] struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Name() *WithResults_Name_Call[T] { + return &WithResults_Name_Call[T]{Call: _e.mock.On("Name")} +} + +func (_c *WithResults_Name_Call[T]) Run(run func()) *WithResults_Name_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Name_Call[T]) Return(_a0 string) *WithResults_Name_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Name_Call[T]) RunAndReturn(run func() string) *WithResults_Name_Call[T] { + _c.Call.Return(run) + return _c +} + +// Reset provides a mock function with given fields: +func (_m *WithResults[T]) Reset() { + _m.Called() +} + +// WithResults_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type WithResults_Reset_Call[T interface{}] struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Reset() *WithResults_Reset_Call[T] { + return &WithResults_Reset_Call[T]{Call: _e.mock.On("Reset")} +} + +func (_c *WithResults_Reset_Call[T]) Run(run func()) *WithResults_Reset_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Reset_Call[T]) Return() *WithResults_Reset_Call[T] { + _c.Call.Return() + return _c +} + +func (_c *WithResults_Reset_Call[T]) RunAndReturn(run func()) *WithResults_Reset_Call[T] { + _c.Call.Return(run) + return _c +} + +// Results provides a mock function with given fields: +func (_m *WithResults[T]) Results() (T, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Results") + } + + var r0 T + var r1 error + if rf, ok := ret.Get(0).(func() (T, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() T); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(T) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Results_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Results' +type WithResults_Results_Call[T interface{}] struct { + *mock.Call +} + +// Results is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Results() *WithResults_Results_Call[T] { + return &WithResults_Results_Call[T]{Call: _e.mock.On("Results")} +} + +func (_c *WithResults_Results_Call[T]) Run(run func()) *WithResults_Results_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Results_Call[T]) Return(_a0 T, _a1 error) *WithResults_Results_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Results_Call[T]) RunAndReturn(run func() (T, error)) *WithResults_Results_Call[T] { + _c.Call.Return(run) + return _c +} + +// SelectFor provides a mock function with given fields: _a0 +func (_m *WithResults[T]) SelectFor(_a0 ...string) (string, error) { + _va := make([]interface{}, len(_a0)) + for _i := range _a0 { + _va[_i] = _a0[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SelectFor") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(...string) (string, error)); ok { + return rf(_a0...) + } + if rf, ok := ret.Get(0).(func(...string) string); ok { + r0 = rf(_a0...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(...string) error); ok { + r1 = rf(_a0...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_SelectFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectFor' +type WithResults_SelectFor_Call[T interface{}] struct { + *mock.Call +} + +// SelectFor is a helper method to define mock.On call +// - _a0 ...string +func (_e *WithResults_Expecter[T]) SelectFor(_a0 ...interface{}) *WithResults_SelectFor_Call[T] { + return &WithResults_SelectFor_Call[T]{Call: _e.mock.On("SelectFor", + append([]interface{}{}, _a0...)...)} +} + +func (_c *WithResults_SelectFor_Call[T]) Run(run func(_a0 ...string)) *WithResults_SelectFor_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *WithResults_SelectFor_Call[T]) Return(_a0 string, _a1 error) *WithResults_SelectFor_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_SelectFor_Call[T]) RunAndReturn(run func(...string) (string, error)) *WithResults_SelectFor_Call[T] { + _c.Call.Return(run) + return _c +} + +// SetDialect provides a mock function with given fields: _a0 +func (_m *WithResults[T]) SetDialect(_a0 sqltemplate.Dialect) { + _m.Called(_a0) +} + +// WithResults_SetDialect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDialect' +type WithResults_SetDialect_Call[T interface{}] struct { + *mock.Call +} + +// SetDialect is a helper method to define mock.On call +// - _a0 sqltemplate.Dialect +func (_e *WithResults_Expecter[T]) SetDialect(_a0 interface{}) *WithResults_SetDialect_Call[T] { + return &WithResults_SetDialect_Call[T]{Call: _e.mock.On("SetDialect", _a0)} +} + +func (_c *WithResults_SetDialect_Call[T]) Run(run func(_a0 sqltemplate.Dialect)) *WithResults_SetDialect_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(sqltemplate.Dialect)) + }) + return _c +} + +func (_c *WithResults_SetDialect_Call[T]) Return() *WithResults_SetDialect_Call[T] { + _c.Call.Return() + return _c +} + +func (_c *WithResults_SetDialect_Call[T]) RunAndReturn(run func(sqltemplate.Dialect)) *WithResults_SetDialect_Call[T] { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: +func (_m *WithResults[T]) Validate() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// WithResults_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type WithResults_Validate_Call[T interface{}] struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Validate() *WithResults_Validate_Call[T] { + return &WithResults_Validate_Call[T]{Call: _e.mock.On("Validate")} +} + +func (_c *WithResults_Validate_Call[T]) Run(run func()) *WithResults_Validate_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Validate_Call[T]) Return(_a0 error) *WithResults_Validate_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Validate_Call[T]) RunAndReturn(run func() error) *WithResults_Validate_Call[T] { + _c.Call.Return(run) + return _c +} + +// NewWithResults creates a new instance of WithResults. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewWithResults[T interface{}](t interface { + mock.TestingT + Cleanup(func()) +}) *WithResults[T] { + mock := &WithResults[T]{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate.go b/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate.go index 1fb3510804a..551a23fca62 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate.go @@ -1,11 +1,18 @@ package sqltemplate import ( + "errors" "regexp" "strings" "text/template" ) +// Package-level errors. +var ( + ErrValidationNotImplemented = errors.New("validation not implemented") + ErrSQLTemplateNoSerialize = errors.New("SQLTemplate should not be serialized") +) + // SQLTemplate provides comprehensive support for SQL templating, handling // dialect traits, execution arguments and scanning arguments. type SQLTemplate struct { @@ -16,22 +23,55 @@ type SQLTemplate struct { // New returns a nee *SQLTemplate that will use the given dialect. func New(d Dialect) *SQLTemplate { - return &SQLTemplate{ - Args: Args{ - d: d, - }, - Dialect: d, - } + ret := new(SQLTemplate) + ret.SetDialect(d) + + return ret } +func (t *SQLTemplate) Reset() { + t.Args.Reset() + t.ScanDest.Reset() +} + +func (t *SQLTemplate) SetDialect(d Dialect) { + t.Reset() + t.Dialect = d + t.Args.d = d +} + +func (t *SQLTemplate) Validate() error { + return ErrValidationNotImplemented +} + +func (t *SQLTemplate) MarshalJSON() ([]byte, error) { + return nil, ErrSQLTemplateNoSerialize +} + +func (t *SQLTemplate) UnmarshalJSON([]byte) error { + return ErrSQLTemplateNoSerialize +} + +//go:generate mockery --with-expecter --name SQLTemplateIface + // SQLTemplateIface can be used as argument in general purpose utilities // expecting a struct embedding *SQLTemplate. type SQLTemplateIface interface { Dialect ArgsIface ScanDestIface + // Reset calls the Reset method of ArgsIface and ScanDestIface. + Reset() + // SetDialect allows reusing the template components. It should first call + // Reset. + SetDialect(Dialect) + // Validate should be implemented to validate a request before executing the + // template. + Validate() error } +//go:generate mockery --with-expecter --name WithResults + // WithResults has an additional method suited for structs embedding // *SQLTemplate and returning a set of rows. type WithResults[T any] interface { @@ -55,11 +95,10 @@ func Execute(t *template.Template, data any) (string, error) { return b.String(), nil } -// FormatSQL is an opinionated formatter for SQL template output that returns -// the code as a oneliner. It can be used to reduce the final code length, for -// debugging, and testing. It is not a propoer and full-fledged SQL parser, so -// it makes the following assumptions, which are also good practices for writing -// your SQL templates: +// FormatSQL is an opinionated formatter for SQL template output. It can be used +// to reduce the final code length, for debugging, and testing. It is not a +// propoer and full-fledged SQL parser, so it makes the following assumptions, +// which are also good practices for writing your SQL templates: // 1. There are no SQL comments. Consider adding your comments as template // comments instead (i.e. "{{/* this is a template comment */}}"). // 2. There are no multiline strings, and strings do not contain consecutive @@ -72,6 +111,7 @@ func FormatSQL(q string) string { for _, f := range formatREs { q = f.re.ReplaceAllString(q, f.replacement) } + q = strings.TrimSpace(q) return q } @@ -88,4 +128,14 @@ var formatREs = []reFormatting{ {re: regexp.MustCompile(` ([)\]}])`), replacement: "$1"}, {re: regexp.MustCompile(` ?, ?`), replacement: ", "}, {re: regexp.MustCompile(` ?([;.:]) ?`), replacement: "$1"}, + + // Add newlines and a bit of visual aid + { + re: regexp.MustCompile(`((UNION|INTERSECT|EXCEPT)( (ALL|DISTINCT))? )?SELECT `), + replacement: "\n${1}SELECT ", + }, + { + re: regexp.MustCompile(` (FROM|WHERE|GROUP BY|HAVING|WINDOW|ORDER BY|LIMIT|OFFSET) `), + replacement: "\n $1 ", + }, } diff --git a/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate_test.go b/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate_test.go index f5f0a9e5d70..c46fc2d7cca 100644 --- a/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate_test.go +++ b/pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate_test.go @@ -1,10 +1,50 @@ package sqltemplate import ( + "encoding/json" + "errors" + "reflect" "testing" "text/template" ) +func TestSQLTemplate(t *testing.T) { + t.Parallel() + + field := reflect.ValueOf(new(struct { + X int + })).Elem().FieldByName("X") + + tmpl := New(MySQL) + tmpl.Arg(1) + _, err := tmpl.Into(field, "colname") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tmpl.SetDialect(SQLite) + a := tmpl.GetArgs() + d := tmpl.GetScanDest() + if len(a) != 0 || len(d) != 0 { + t.Fatalf("unexpected values after SetDialect(). Args: %v, ScanDest: %v", + a, d) + } + + b, err := json.Marshal(tmpl) + if b != nil || !errors.Is(err, ErrSQLTemplateNoSerialize) { + t.Fatalf("should fail serialization with ErrSQLTemplateNoSerialize") + } + + err = json.Unmarshal([]byte(`{}`), &tmpl) + if !errors.Is(err, ErrSQLTemplateNoSerialize) { + t.Fatalf("should fail deserialization with ErrSQLTemplateNoSerialize") + } + + err = tmpl.Validate() + if !errors.Is(err, ErrValidationNotImplemented) { + t.Fatalf("should fail with ErrValidationNotImplemented") + } +} + func TestExecute(t *testing.T) { t.Parallel() @@ -26,3 +66,26 @@ func TestExecute(t *testing.T) { t.Fatalf("unexpected result, txt: %q, err: %v", txt, err) } } + +func TestFormatSQL(t *testing.T) { + t.Parallel() + + // TODO: improve testing + + const ( + input = ` + SELECT * + FROM "mytab" AS t + WHERE "id">= 3 AND "str" = ? ; + ` + expected = `SELECT * + FROM "mytab" AS t + WHERE "id" >= 3 AND "str" = ?;` + ) + + got := FormatSQL(input) + if expected != got { + t.Fatalf("Unexpected output.\n\tExpected: %s\n\tActual: %s", expected, + got) + } +} diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_delete_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_delete_mysql_sqlite.sql new file mode 100644 index 00000000000..1df5d11f3e9 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_delete_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "entity" WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_delete_postgres.sql b/pkg/services/store/entity/sqlstash/testdata/entity_delete_postgres.sql new file mode 100644 index 00000000000..8968e625fab --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_delete_postgres.sql @@ -0,0 +1 @@ +DELETE FROM "entity" WHERE 1 = 1 AND "namespace" = $1 AND "group" = $2 AND "resource" = $3 AND "name" = $4; diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_1_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_1_mysql_sqlite.sql new file mode 100644 index 00000000000..37a3dcae9ed --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_1_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity_folder" + ("guid", "namespace", "name", "slug_path", "tree", "depth", "lft", "rgt", "detached") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_2_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_2_mysql_sqlite.sql new file mode 100644 index 00000000000..4a8c005bc17 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_folder_insert_2_mysql_sqlite.sql @@ -0,0 +1,5 @@ +INSERT INTO "entity_folder" + ("guid", "namespace", "name", "slug_path", "tree", "depth", "lft", "rgt", "detached") + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_history_insert_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_history_insert_mysql_sqlite.sql new file mode 100644 index 00000000000..98d4850dd8a --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_history_insert_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity_history" + ("guid", "resource_version", "key", "group", "group_version", "resource", "namespace", "name", "folder", "meta", "body", "status", "size", "etag", "created_at", "created_by", "updated_at", "updated_by", "origin", "origin_key", "origin_ts", "title", "slug", "description", "message", "labels", "fields", "errors", "action") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_history_read_full_mysql.sql b/pkg/services/store/entity/sqlstash/testdata/entity_history_read_full_mysql.sql new file mode 100644 index 00000000000..92856fccc9f --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_history_read_full_mysql.sql @@ -0,0 +1,5 @@ +SELECT e."guid", e."resource_version", e."key", e."group", e."group_version", e."resource", e."namespace", e."name", e."folder", e."meta", e."body", e."status", e."size", e."etag", e."created_at", e."created_by", e."updated_at", e."updated_by", e."origin", e."origin_key", e."origin_ts", e."title", e."slug", e."description", e."message", e."labels", e."fields", e."errors", e."action" + FROM "entity_history" AS e + WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ? AND "resource_version" <= ? + ORDER BY "resource_version" DESC + LIMIT 1 FOR UPDATE NOWAIT; diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_insert_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_insert_mysql_sqlite.sql new file mode 100644 index 00000000000..eba95376d9b --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_insert_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity" + ("guid", "resource_version", "key", "group", "group_version", "resource", "namespace", "name", "folder", "meta", "body", "status", "size", "etag", "created_at", "created_by", "updated_at", "updated_by", "origin", "origin_key", "origin_ts", "title", "slug", "description", "message", "labels", "fields", "errors", "action") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_1_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_1_mysql_sqlite.sql new file mode 100644 index 00000000000..99e02bd77f1 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_1_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "entity_labels" WHERE 1 = 1 AND "guid" = ? AND "label" NOT IN (?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_2_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_2_mysql_sqlite.sql new file mode 100644 index 00000000000..59d29a12d98 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_labels_delete_2_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "entity_labels" WHERE 1 = 1 AND "guid" = ? AND "label" NOT IN (?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_1_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_1_mysql_sqlite.sql new file mode 100644 index 00000000000..7db16f154c4 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_1_mysql_sqlite.sql @@ -0,0 +1,2 @@ +INSERT INTO "entity_labels" ("guid", "label", "value") + VALUES (?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_2_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_2_mysql_sqlite.sql new file mode 100644 index 00000000000..f16e7766651 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_labels_insert_2_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity_labels" ("guid", "label", "value") VALUES + (?, ?, ?), + (?, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_list_folder_elements_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_list_folder_elements_mysql_sqlite.sql new file mode 100644 index 00000000000..2f5e831fe37 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_list_folder_elements_mysql_sqlite.sql @@ -0,0 +1,3 @@ +SELECT "guid", "name", "folder", "name", "slug" + FROM "entity" + WHERE 1 = 1 AND "group" = ? AND "resource" = ? AND "namespace" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_read_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_read_mysql_sqlite.sql new file mode 100644 index 00000000000..00497962a60 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_read_mysql_sqlite.sql @@ -0,0 +1,3 @@ +SELECT e."guid", e."resource_version", e."key", e."group", e."group_version", e."resource", e."namespace", e."name", e."folder", e."meta", e."body", e."status", e."size", e."etag", e."created_at", e."created_by", e."updated_at", e."updated_by", e."origin", e."origin_key", e."origin_ts", e."title", e."slug", e."description", e."message", e."labels", e."fields", e."errors", e."action" + FROM "entity" AS e + WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/entity_update_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/entity_update_mysql_sqlite.sql new file mode 100644 index 00000000000..e61ceaf5608 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/entity_update_mysql_sqlite.sql @@ -0,0 +1,2 @@ +UPDATE "entity" SET "resource_version" = ?, "group_version" = ?, "folder" = ?, "meta" = ?, "body" = ?, "status" = ?, "size" = ?, "etag" = ?, "updated_at" = ?, "updated_by" = ?, "origin" = ?, "origin_key" = ?, "origin_ts" = ?, "title" = ?, "slug" = ?, "description" = ?, "message" = ?, "labels" = ?, "fields" = ?, "errors" = ?, "action" = ? + WHERE "guid" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/grpc-req-create.json b/pkg/services/store/entity/sqlstash/testdata/grpc-req-create.json new file mode 100644 index 00000000000..27a8fd2ca07 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/grpc-req-create.json @@ -0,0 +1,21 @@ +{ + "entity": { + "guid": "b0199c60-5f3a-41be-9ba6-7a52f1de83ff", + "group": "playlist.grafana.app", + "resource": "playlists", + "namespace": "default", + "name": "adnj1llchbbi8a", + "group_version": "v0alpha1", + "key": "/playlist.grafana.app/playlists/namespaces/default/adnj1llchbbi8a", + "meta": "eyJtZXRhZGF0YSI6eyJuYW1lIjoiYWRuajFsbGNoYmJpOGEiLCJuYW1lc3BhY2UiOiJkZWZhdWx0IiwidWlkIjoiYjAxOTljNjAtNWYzYS00MWJlLTliYTYtN2E1MmYxZGU4M2ZmIiwiY3JlYXRpb25UaW1lc3RhbXAiOiIyMDI0LTA2LTAyVDAzOjI4OjE3WiIsImFubm90YXRpb25zIjp7ImdyYWZhbmEuYXBwL29yaWdpbktleSI6IjIiLCJncmFmYW5hLmFwcC9vcmlnaW5OYW1lIjoiU1FMIiwiZ3JhZmFuYS5hcHAvb3JpZ2luVGltZXN0YW1wIjoiMjAyNC0wNi0wMlQwMzoyODoxN1oiLCJncmFmYW5hLmFwcC91cGRhdGVkVGltZXN0YW1wIjoiMjAyNC0wNi0wMlQwMzoyODoxN1oifX19", + "body": "eyJraW5kIjoiUGxheWxpc3QiLCJhcGlWZXJzaW9uIjoicGxheWxpc3QuZ3JhZmFuYS5hcHAvdjBhbHBoYTEiLCJtZXRhZGF0YSI6eyJuYW1lIjoiYWRuajFsbGNoYmJpOGEiLCJuYW1lc3BhY2UiOiJkZWZhdWx0IiwidWlkIjoiYjAxOTljNjAtNWYzYS00MWJlLTliYTYtN2E1MmYxZGU4M2ZmIiwiY3JlYXRpb25UaW1lc3RhbXAiOiIyMDI0LTA2LTAyVDAzOjI4OjE3WiIsImFubm90YXRpb25zIjp7ImdyYWZhbmEuYXBwL29yaWdpbktleSI6IjIiLCJncmFmYW5hLmFwcC9vcmlnaW5OYW1lIjoiU1FMIiwiZ3JhZmFuYS5hcHAvb3JpZ2luVGltZXN0YW1wIjoiMjAyNC0wNi0wMlQwMzoyODoxN1oiLCJncmFmYW5hLmFwcC91cGRhdGVkVGltZXN0YW1wIjoiMjAyNC0wNi0wMlQwMzoyODoxN1oifX0sInNwZWMiOnsidGl0bGUiOiJ0ZXN0IHBsYXlsaXN0IiwiaW50ZXJ2YWwiOiI1bSIsIml0ZW1zIjpbeyJ0eXBlIjoiZGFzaGJvYXJkX2J5X3VpZCIsInZhbHVlIjoiY2RuaXY1M2dtZDR3MGUifV19fQo=", + "title": "test playlist", + "created_at": 1717298897750, + "updated_at": 1717298897000, + "origin": { + "source": "SQL", + "key": "2", + "time": 1717298897000 + } + } +} diff --git a/pkg/services/store/entity/sqlstash/testdata/grpc-req-delete.json b/pkg/services/store/entity/sqlstash/testdata/grpc-req-delete.json new file mode 100644 index 00000000000..4802693b534 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/grpc-req-delete.json @@ -0,0 +1,3 @@ +{ + "key": "/playlist.grafana.app/playlists/namespaces/default/sdfsdfsdf" +} diff --git a/pkg/services/store/entity/sqlstash/testdata/grpc-req-update.json b/pkg/services/store/entity/sqlstash/testdata/grpc-req-update.json new file mode 100644 index 00000000000..a94dabcb233 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/grpc-req-update.json @@ -0,0 +1,18 @@ +{ + "entity": { + "guid": "3c769b2e-aaa7-46f6-ab83-e038050a6a75", + "resource_version": 1, + "group": "playlist.grafana.app", + "resource": "playlists", + "namespace": "default", + "name": "sdfsdfsdf", + "group_version": "v0alpha1", + "key": "/playlist.grafana.app/playlists/namespaces/default/sdfsdfsdf", + "meta": "eyJtZXRhZGF0YSI6eyJuYW1lIjoic2Rmc2Rmc2RmIiwibmFtZXNwYWNlIjoiZGVmYXVsdCIsInVpZCI6IjNjNzY5YjJlLWFhYTctNDZmNi1hYjgzLWUwMzgwNTBhNmE3NSIsInJlc291cmNlVmVyc2lvbiI6IjEiLCJjcmVhdGlvblRpbWVzdGFtcCI6IjIwMjQtMDYtMDJUMDM6NDk6MjlaIiwibWFuYWdlZEZpZWxkcyI6W3sibWFuYWdlciI6Ik1vemlsbGEiLCJvcGVyYXRpb24iOiJVcGRhdGUiLCJhcGlWZXJzaW9uIjoicGxheWxpc3QuZ3JhZmFuYS5hcHAvdjBhbHBoYTEiLCJ0aW1lIjoiMjAyNC0wNi0wMlQwMzo1Mzo1NVoiLCJmaWVsZHNUeXBlIjoiRmllbGRzVjEiLCJmaWVsZHNWMSI6eyJmOnNwZWMiOnsiZjppbnRlcnZhbCI6e30sImY6aXRlbXMiOnt9LCJmOnRpdGxlIjp7fX19fV19fQ==", + "body": "eyJraW5kIjoiUGxheWxpc3QiLCJhcGlWZXJzaW9uIjoicGxheWxpc3QuZ3JhZmFuYS5hcHAvdjBhbHBoYTEiLCJtZXRhZGF0YSI6eyJuYW1lIjoic2Rmc2Rmc2RmIiwibmFtZXNwYWNlIjoiZGVmYXVsdCIsInVpZCI6IjNjNzY5YjJlLWFhYTctNDZmNi1hYjgzLWUwMzgwNTBhNmE3NSIsInJlc291cmNlVmVyc2lvbiI6IjEiLCJjcmVhdGlvblRpbWVzdGFtcCI6IjIwMjQtMDYtMDJUMDM6NDk6MjlaIiwibWFuYWdlZEZpZWxkcyI6W3sibWFuYWdlciI6Ik1vemlsbGEiLCJvcGVyYXRpb24iOiJVcGRhdGUiLCJhcGlWZXJzaW9uIjoicGxheWxpc3QuZ3JhZmFuYS5hcHAvdjBhbHBoYTEiLCJ0aW1lIjoiMjAyNC0wNi0wMlQwMzo1Mzo1NVoiLCJmaWVsZHNUeXBlIjoiRmllbGRzVjEiLCJmaWVsZHNWMSI6eyJmOnNwZWMiOnsiZjppbnRlcnZhbCI6e30sImY6aXRlbXMiOnt9LCJmOnRpdGxlIjp7fX19fV19LCJzcGVjIjp7InRpdGxlIjoieHpjdnp4Y3Zxd2Vxd2UiLCJpbnRlcnZhbCI6IjVtIiwiaXRlbXMiOlt7InR5cGUiOiJkYXNoYm9hcmRfYnlfdWlkIiwidmFsdWUiOiJjZG5pdjUzZ21kNHcwZSJ9XX19Cg==", + "title": "xzcvzxcvqweqwe", + "created_at": 1717300169240, + "origin": {} + }, + "previous_version": 1 +} diff --git a/pkg/services/store/entity/sqlstash/testdata/grpc-res-entity.json b/pkg/services/store/entity/sqlstash/testdata/grpc-res-entity.json new file mode 100644 index 00000000000..b0bf49ededf --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/grpc-res-entity.json @@ -0,0 +1,21 @@ +{ + "guid": "5842f146-07b9-405b-af76-b4c4b2612518", + "resource_version": 6, + "group": "playlist.grafana.app", + "resource": "playlists", + "namespace": "default", + "name": "sdfsdfsdf", + "group_version": "v0alpha1", + "key": "/playlist.grafana.app/playlists/namespaces/default/sdfsdfsdf", + "meta": "eyJtZXRhZGF0YSI6eyJuYW1lIjoic2Rmc2Rmc2RmIiwibmFtZXNwYWNlIjoiZGVmYXVsdCIsInVpZCI6IjAyZmVhOGVlLTk2ZDYtNGIzMy04ZGI5LTU5MmI0NzU4NTM4NSIsImNyZWF0aW9uVGltZXN0YW1wIjoiMjAyNC0wNi0wNFQxNToxODozNFoiLCJtYW5hZ2VkRmllbGRzIjpbeyJtYW5hZ2VyIjoiTW96aWxsYSIsIm9wZXJhdGlvbiI6IlVwZGF0ZSIsImFwaVZlcnNpb24iOiJwbGF5bGlzdC5ncmFmYW5hLmFwcC92MGFscGhhMSIsInRpbWUiOiIyMDI0LTA2LTA0VDE1OjE4OjM0WiIsImZpZWxkc1R5cGUiOiJGaWVsZHNWMSIsImZpZWxkc1YxIjp7ImY6c3BlYyI6eyJmOmludGVydmFsIjp7fSwiZjppdGVtcyI6e30sImY6dGl0bGUiOnt9fX19XX19", + "body": "eyJraW5kIjoiUGxheWxpc3QiLCJhcGlWZXJzaW9uIjoicGxheWxpc3QuZ3JhZmFuYS5hcHAvdjBhbHBoYTEiLCJtZXRhZGF0YSI6eyJuYW1lIjoic2Rmc2Rmc2RmIiwibmFtZXNwYWNlIjoiZGVmYXVsdCIsInVpZCI6IjAyZmVhOGVlLTk2ZDYtNGIzMy04ZGI5LTU5MmI0NzU4NTM4NSIsImNyZWF0aW9uVGltZXN0YW1wIjoiMjAyNC0wNi0wNFQxNToxODozNFoiLCJtYW5hZ2VkRmllbGRzIjpbeyJtYW5hZ2VyIjoiTW96aWxsYSIsIm9wZXJhdGlvbiI6IlVwZGF0ZSIsImFwaVZlcnNpb24iOiJwbGF5bGlzdC5ncmFmYW5hLmFwcC92MGFscGhhMSIsInRpbWUiOiIyMDI0LTA2LTA0VDE1OjE4OjM0WiIsImZpZWxkc1R5cGUiOiJGaWVsZHNWMSIsImZpZWxkc1YxIjp7ImY6c3BlYyI6eyJmOmludGVydmFsIjp7fSwiZjppdGVtcyI6e30sImY6dGl0bGUiOnt9fX19XX0sInNwZWMiOnsidGl0bGUiOiJ4emN2enhjdiIsImludGVydmFsIjoiNW0iLCJpdGVtcyI6W3sidHlwZSI6ImRhc2hib2FyZF9ieV91aWQiLCJ2YWx1ZSI6ImNkbml2NTNnbWQ0dzBlIn1dfX0K", + "title": "xzcvzxcv", + "size": 540, + "ETag": "3225612903101e3b94f458cfc79baf89", + "created_at": 1717514314565, + "created_by": "user:1:admin", + "updated_at": 1717514314565, + "updated_by": "user:1:admin", + "origin": {}, + "action": 1 +} diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_get_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_get_mysql_sqlite.sql new file mode 100644 index 00000000000..1c031ce33d3 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_get_mysql_sqlite.sql @@ -0,0 +1,3 @@ +SELECT "resource_version", "created_at", "updated_at" + FROM "kind_version" + WHERE 1 = 1 AND "group" = ? AND "resource" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_inc_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_inc_mysql_sqlite.sql new file mode 100644 index 00000000000..2697a0e6f6b --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_inc_mysql_sqlite.sql @@ -0,0 +1,4 @@ +UPDATE "kind_version" + SET "resource_version" = ? + 1, + "updated_at" = ? + WHERE 1 = 1 AND "group" = ? AND "resource" = ? AND "resource_version" = ?; diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_insert_mysql_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_insert_mysql_sqlite.sql new file mode 100644 index 00000000000..b3819f90c8a --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_insert_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "kind_version" + ("group", "resource", "resource_version", "created_at", "updated_at") + VALUES (?, ?, 1, ?, ?); diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_mysql.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_mysql.sql new file mode 100644 index 00000000000..6af0d09fefc --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_mysql.sql @@ -0,0 +1,3 @@ +SELECT "resource_version" + FROM "kind_version" + WHERE 1 = 1 AND "group" = ? AND "resource" = ? FOR UPDATE; diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_postgres.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_postgres.sql new file mode 100644 index 00000000000..c7e911b2662 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_postgres.sql @@ -0,0 +1,3 @@ +SELECT "resource_version" + FROM "kind_version" + WHERE 1 = 1 AND "group" = $1 AND "resource" = $2 FOR UPDATE; diff --git a/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_sqlite.sql b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_sqlite.sql new file mode 100644 index 00000000000..e9d6605ecc0 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/testdata/kind_version_lock_sqlite.sql @@ -0,0 +1,3 @@ +SELECT "resource_version" + FROM "kind_version" + WHERE 1 = 1 AND "group" = ? AND "resource" = ?; diff --git a/pkg/services/store/entity/sqlstash/utils.go b/pkg/services/store/entity/sqlstash/utils.go index 82b6faff11f..84768b3f06d 100644 --- a/pkg/services/store/entity/sqlstash/utils.go +++ b/pkg/services/store/entity/sqlstash/utils.go @@ -15,11 +15,13 @@ import ( ) func createETag(body []byte, meta []byte, status []byte) string { + // TODO: can we change this to something more modern like sha256? h := md5.New() _, _ = h.Write(meta) _, _ = h.Write(body) _, _ = h.Write(status) hash := h.Sum(nil) + return hex.EncodeToString(hash[:]) } @@ -34,8 +36,7 @@ func getCurrentUser(ctx context.Context) (string, error) { return store.GetUserIDString(user), nil } -// ptrOr returns the first non-nil pointer in the least or a new non-nil -// pointer. +// ptrOr returns the first non-nil pointer in the list or a new non-nil pointer. func ptrOr[P ~*E, E any](ps ...P) P { for _, p := range ps { if p != nil { @@ -46,8 +47,8 @@ func ptrOr[P ~*E, E any](ps ...P) P { return P(new(E)) } -// sliceOr returns the first slice that has at least one element, or a non-nil -// empty slice. +// sliceOr returns the first slice that has at least one element, or a new empty +// slice. func sliceOr[S ~[]E, E comparable](vals ...S) S { for _, s := range vals { if len(s) > 0 { @@ -58,7 +59,7 @@ func sliceOr[S ~[]E, E comparable](vals ...S) S { return S{} } -// mapOr returns the first map that has at least one element, or a non-nil empty +// mapOr returns the first map that has at least one element, or a new empty // map. func mapOr[M ~map[K]V, K comparable, V any](vals ...M) M { for _, m := range vals { @@ -70,61 +71,16 @@ func mapOr[M ~map[K]V, K comparable, V any](vals ...M) M { return M{} } -// countTrue returns the number of true values in its arguments. -func countTrue(bools ...bool) uint64 { - var ret uint64 - for _, b := range bools { - if b { - ret++ - } - } - - return ret -} - -// query uses `req` as input and output for a zero or more row-returning query -// generated with `tmpl`, and executed in `x`. -func query[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) ([]T, error) { - rawQuery, err := sqltemplate.Execute(tmpl, req) - if err != nil { - return nil, fmt.Errorf("execute template: %w", err) - } - query := sqltemplate.FormatSQL(rawQuery) - - rows, err := x.QueryContext(ctx, query, req.GetArgs()...) - if err != nil { - return nil, SQLError{ - Err: err, - CallType: "Query", - Arguments: req.GetArgs(), - ScanDest: req.GetScanDest(), - Query: query, - RawQuery: rawQuery, - } - } - defer rows.Close() //nolint:errcheck - - var ret []T - for rows.Next() { - res, err := scanRow(rows, req) - if err != nil { - return nil, err - } - ret = append(ret, res) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("rows err: %w", err) - } - - return ret, nil -} - // queryRow uses `req` as input and output for a single-row returning query // generated with `tmpl`, and executed in `x`. func queryRow[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) (T, error) { var zero T + if err := req.Validate(); err != nil { + return zero, fmt.Errorf("query: invalid request for template %q: %w", + tmpl.Name(), err) + } + rawQuery, err := sqltemplate.Execute(tmpl, req) if err != nil { return zero, fmt.Errorf("execute template: %w", err) @@ -134,12 +90,13 @@ func queryRow[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Tem row := x.QueryRowContext(ctx, query, req.GetArgs()...) if err := row.Err(); err != nil { return zero, SQLError{ - Err: err, - CallType: "QueryRow", - Arguments: req.GetArgs(), - ScanDest: req.GetScanDest(), - Query: query, - RawQuery: rawQuery, + Err: err, + CallType: "QueryRow", + TemplateName: tmpl.Name(), + arguments: req.GetArgs(), + ScanDest: req.GetScanDest(), + Query: query, + RawQuery: rawQuery, } } @@ -168,6 +125,11 @@ func scanRow[T any](sc scanner, req sqltemplate.WithResults[T]) (zero T, err err // exec uses `req` as input for a non-data returning query generated with // `tmpl`, and executed in `x`. func exec(ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.SQLTemplateIface) (sql.Result, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("exec: invalid request for template %q: %w", + tmpl.Name(), err) + } + rawQuery, err := sqltemplate.Execute(tmpl, req) if err != nil { return nil, fmt.Errorf("execute template: %w", err) @@ -177,11 +139,12 @@ func exec(ctx context.Context, x db.ContextExecer, tmpl *template.Template, req res, err := x.ExecContext(ctx, query, req.GetArgs()...) if err != nil { return nil, SQLError{ - Err: err, - CallType: "Exec", - Arguments: req.GetArgs(), - Query: query, - RawQuery: rawQuery, + Err: err, + CallType: "Exec", + TemplateName: tmpl.Name(), + arguments: req.GetArgs(), + Query: query, + RawQuery: rawQuery, } } diff --git a/pkg/services/store/entity/sqlstash/utils_test.go b/pkg/services/store/entity/sqlstash/utils_test.go new file mode 100644 index 00000000000..70d02263623 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/utils_test.go @@ -0,0 +1,525 @@ +package sqlstash + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "regexp" + "strings" + "testing" + "text/template" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/store/entity/db" + "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" + "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" + sqltemplateMocks "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate/mocks" + "github.com/grafana/grafana/pkg/util/testutil" +) + +// newMockDBNopSQL returns a db.DB and a sqlmock.Sqlmock that doesn't validates +// SQL. This is only meant to be used to test wrapping utilities exec, query and +// queryRow, where the actual SQL is not relevant to the unit tests, but rather +// how the possible derived error conditions handled. +func newMockDBNopSQL(t *testing.T) (db.DB, sqlmock.Sqlmock) { + t.Helper() + + db, mock, err := sqlmock.New( + sqlmock.MonitorPingsOption(true), + sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc( + func(expectedSQL, actualSQL string) error { + return nil + }, + )), + ) + + return newUnitTestDB(t, db, mock, err) +} + +// newMockDBMatchWords returns a db.DB and a sqlmock.Sqlmock that will match SQL +// by splitting the expected SQL string into words, and then try to find all of +// them in the actual SQL, in the given order, case insensitively. Prepend a +// word with a `!` to say that word should not be found. +func newMockDBMatchWords(t *testing.T) (db.DB, sqlmock.Sqlmock) { + t.Helper() + + db, mock, err := sqlmock.New( + sqlmock.MonitorPingsOption(true), + sqlmock.QueryMatcherOption( + sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + actualSQL = strings.ToLower(sqltemplate.FormatSQL(actualSQL)) + expectedSQL = strings.ToLower(expectedSQL) + + var offset int + for _, vv := range mockDBMatchWordsRE.FindAllStringSubmatch(expectedSQL, -1) { + v := vv[1] + + var shouldNotMatch bool + if v != "" && v[0] == '!' { + v = v[1:] + shouldNotMatch = true + } + if v == "" { + return fmt.Errorf("invalid expected word %q in %q", v, + expectedSQL) + } + + reWord, err := regexp.Compile(`\b` + regexp.QuoteMeta(v) + `\b`) + if err != nil { + return fmt.Errorf("compile word %q from expected SQL: %s", v, + expectedSQL) + } + + if shouldNotMatch { + if reWord.MatchString(actualSQL[offset:]) { + return fmt.Errorf("actual SQL fragent should not cont"+ + "ain %q but it does\n\tFragment: %s\n\tFull SQL: %s", + v, actualSQL[offset:], actualSQL) + } + } else { + loc := reWord.FindStringIndex(actualSQL[offset:]) + if len(loc) == 0 { + return fmt.Errorf("actual SQL fragment should contain "+ + "%q but it doesn't\n\tFragment: %s\n\tFull SQL: %s", + v, actualSQL[offset:], actualSQL) + } + offset = loc[1] // advance the offset + } + } + + return nil + }, + ), + ), + ) + + return newUnitTestDB(t, db, mock, err) +} + +var mockDBMatchWordsRE = regexp.MustCompile(`(?:\W|\A)(!?\w+)\b`) + +func newUnitTestDB(t *testing.T, db *sql.DB, mock sqlmock.Sqlmock, err error) (db.DB, sqlmock.Sqlmock) { + t.Helper() + + require.NoError(t, err) + + return dbimpl.NewDB(db, "sqlmock"), mock +} + +// mockResults aids in testing code paths with queries returning large number of +// values, like those returning *entity.Entity. This is because we want to +// emulate returning the same row columns and row values the same as a real +// database would do. This utility the same template SQL that is expected to be +// used to help populate all the expected fields. +// fileds +type mockResults[T any] struct { + t *testing.T + tmpl *template.Template + data sqltemplate.WithResults[T] + rows *sqlmock.Rows +} + +// newMockResults returns a new *mockResults. If you want to emulate a call +// returning zero rows, then immediately call the Row method afterward. +func newMockResults[T any](t *testing.T, mock sqlmock.Sqlmock, tmpl *template.Template, data sqltemplate.WithResults[T]) *mockResults[T] { + t.Helper() + + data.Reset() + err := tmpl.Execute(io.Discard, data) + require.NoError(t, err) + rows := mock.NewRows(data.GetColNames()) + + return &mockResults[T]{ + t: t, + tmpl: tmpl, + data: data, + rows: rows, + } +} + +// AddCurrentData uses the values contained in the `data` argument used during +// creation to populate a new expected row. It will access `data` with pointers, +// so you should replace the internal values of `data` with freshly allocated +// results to return different rows. +func (r *mockResults[T]) AddCurrentData() *mockResults[T] { + r.t.Helper() + + r.data.Reset() + err := r.tmpl.Execute(io.Discard, r.data) + require.NoError(r.t, err) + + d := r.data.GetScanDest() + dv := make([]driver.Value, len(d)) + for i, v := range d { + dv[i] = v + } + r.rows.AddRow(dv...) + + return r +} + +// Rows returns the *sqlmock.Rows object built. +func (r *mockResults[T]) Rows() *sqlmock.Rows { + return r.rows +} + +func TestCreateETag(t *testing.T) { + t.Parallel() + + v := createETag(nil, nil, nil) + require.Equal(t, "d41d8cd98f00b204e9800998ecf8427e", v) +} + +func TestGetCurrentUser(t *testing.T) { + t.Parallel() + + ctx := testutil.NewDefaultTestContext(t) + username, err := getCurrentUser(ctx) + require.NotEmpty(t, username) + require.NoError(t, err) + + ctx = ctx.WithUser(nil) + username, err = getCurrentUser(ctx) + require.Empty(t, username) + require.Error(t, err) + require.ErrorIs(t, err, ErrUserNotFoundInContext) +} + +func TestPtrOr(t *testing.T) { + t.Parallel() + + p := ptrOr[*int]() + require.NotNil(t, p) + require.Zero(t, *p) + + p = ptrOr[*int](nil, nil, nil, nil, nil, nil) + require.NotNil(t, p) + require.Zero(t, *p) + + v := 42 + v2 := 5 + p = ptrOr(nil, nil, nil, &v, nil, &v2, nil, nil) + require.NotNil(t, p) + require.Equal(t, v, *p) + + p = ptrOr(nil, nil, nil, &v) + require.NotNil(t, p) + require.Equal(t, v, *p) +} + +func TestSliceOr(t *testing.T) { + t.Parallel() + + p := sliceOr[[]int]() + require.NotNil(t, p) + require.Len(t, p, 0) + + p = sliceOr[[]int](nil, nil, nil, nil) + require.NotNil(t, p) + require.Len(t, p, 0) + + p = sliceOr([]int{}, []int{}, []int{}, []int{}) + require.NotNil(t, p) + require.Len(t, p, 0) + + v := []int{1, 2} + p = sliceOr([]int{}, nil, []int{}, v, nil, []int{}, []int{10}, nil) + require.NotNil(t, p) + require.Equal(t, v, p) + + p = sliceOr([]int{}, nil, []int{}, v) + require.NotNil(t, p) + require.Equal(t, v, p) +} + +func TestMapOr(t *testing.T) { + t.Parallel() + + p := mapOr[map[string]int]() + require.NotNil(t, p) + require.Len(t, p, 0) + + p = mapOr(nil, map[string]int(nil), nil, map[string]int{}, nil) + require.NotNil(t, p) + require.Len(t, p, 0) + + v := map[string]int{"a": 0, "b": 1} + v2 := map[string]int{"c": 2, "d": 3} + + p = mapOr(nil, map[string]int(nil), v, v2, nil, map[string]int{}, nil) + require.NotNil(t, p) + require.Equal(t, v, p) + + p = mapOr(nil, map[string]int(nil), v) + require.NotNil(t, p) + require.Equal(t, v, p) +} + +var ( + validTestTmpl = template.Must(template.New("test").Parse("nothing special")) + invalidTestTmpl = template.New("no definition should fail to exec") + errTest = errors.New("because of reasons") +) + +// expectRows is a testing helper to keep mocks in sync when adding rows to a +// mocked SQL result. This is a helper to test `query` and `queryRow`. +type expectRows[T any] struct { + *sqlmock.Rows + ExpectedResults []T + + req *sqltemplateMocks.WithResults[T] +} + +func newReturnsRow[T any](dbmock sqlmock.Sqlmock, req *sqltemplateMocks.WithResults[T]) *expectRows[T] { + return &expectRows[T]{ + Rows: dbmock.NewRows(nil), + req: req, + } +} + +// Add adds a new value that should be returned by the `query` or `queryRow` +// operation. +func (r *expectRows[T]) Add(value T, err error) *expectRows[T] { + r.req.EXPECT().GetScanDest().Return(nil).Once() + r.req.EXPECT().Results().Return(value, err).Once() + r.Rows.AddRow() + r.ExpectedResults = append(r.ExpectedResults, value) + + return r +} + +func TestQueryRow(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewWithResults[int64](t) + db, dbmock := newMockDBNopSQL(t) + rows := newReturnsRow(dbmock, req) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + req.EXPECT().GetArgs().Return(nil).Once() + rows.Add(1, nil) + dbmock.ExpectQuery("").WillReturnRows(rows.Rows) + + // execute and assert + res, err := queryRow(ctx, db, validTestTmpl, req) + require.NoError(t, err) + require.Equal(t, rows.ExpectedResults[0], res) + }) + + t.Run("invalid request", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewWithResults[int64](t) + db, _ := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(errTest).Once() + + // execute and assert + res, err := queryRow(ctx, db, invalidTestTmpl, req) + require.Zero(t, res) + require.Error(t, err) + require.ErrorContains(t, err, "invalid request") + }) + + t.Run("error executing template", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewWithResults[int64](t) + db, _ := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + + // execute and assert + res, err := queryRow(ctx, db, invalidTestTmpl, req) + require.Zero(t, res) + require.Error(t, err) + require.ErrorContains(t, err, "execute template") + }) + + t.Run("error executing query", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewWithResults[int64](t) + db, dbmock := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + req.EXPECT().GetArgs().Return(nil) + req.EXPECT().GetScanDest().Return(nil).Maybe() + dbmock.ExpectQuery("").WillReturnError(errTest) + + // execute and assert + res, err := queryRow(ctx, db, validTestTmpl, req) + require.Zero(t, res) + require.Error(t, err) + require.ErrorAs(t, err, new(SQLError)) + }) +} + +// scannerFunc is an adapter for the `scanner` interface. +type scannerFunc func(dest ...any) error + +func (f scannerFunc) Scan(dest ...any) error { + return f(dest...) +} + +func TestScanRow(t *testing.T) { + t.Parallel() + + const value int64 = 1 + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + // test declarations + req := sqltemplateMocks.NewWithResults[int64](t) + sc := scannerFunc(func(dest ...any) error { + return nil + }) + + // setup expectations + req.EXPECT().GetScanDest().Return(nil).Once() + req.EXPECT().Results().Return(value, nil).Once() + + // execute and assert + res, err := scanRow(sc, req) + require.NoError(t, err) + require.Equal(t, value, res) + }) + + t.Run("scan error", func(t *testing.T) { + t.Parallel() + + // test declarations + req := sqltemplateMocks.NewWithResults[int64](t) + sc := scannerFunc(func(dest ...any) error { + return errTest + }) + + // setup expectations + req.EXPECT().GetScanDest().Return(nil).Once() + + // execute and assert + res, err := scanRow(sc, req) + require.Zero(t, res) + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) + + t.Run("results error", func(t *testing.T) { + t.Parallel() + + // test declarations + req := sqltemplateMocks.NewWithResults[int64](t) + sc := scannerFunc(func(dest ...any) error { + return nil + }) + + // setup expectations + req.EXPECT().GetScanDest().Return(nil).Once() + req.EXPECT().Results().Return(0, errTest).Once() + + // execute and assert + res, err := scanRow(sc, req) + require.Zero(t, res) + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) +} + +func TestExec(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewSQLTemplateIface(t) + db, dbmock := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + req.EXPECT().GetArgs().Return(nil).Once() + dbmock.ExpectExec("").WillReturnResult(sqlmock.NewResult(0, 0)) + + // execute and assert + res, err := exec(ctx, db, validTestTmpl, req) + require.NoError(t, err) + require.NotNil(t, res) + }) + + t.Run("invalid request", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewSQLTemplateIface(t) + db, _ := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(errTest).Once() + + // execute and assert + res, err := exec(ctx, db, invalidTestTmpl, req) + require.Nil(t, res) + require.Error(t, err) + require.ErrorContains(t, err, "invalid request") + }) + + t.Run("error executing template", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewSQLTemplateIface(t) + db, _ := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + + // execute and assert + res, err := exec(ctx, db, invalidTestTmpl, req) + require.Nil(t, res) + require.Error(t, err) + require.ErrorContains(t, err, "execute template") + }) + + t.Run("error executing SQL", func(t *testing.T) { + t.Parallel() + + // test declarations + ctx := testutil.NewDefaultTestContext(t) + req := sqltemplateMocks.NewSQLTemplateIface(t) + db, dbmock := newMockDBNopSQL(t) + + // setup expectations + req.EXPECT().Validate().Return(nil).Once() + req.EXPECT().GetArgs().Return(nil) + dbmock.ExpectExec("").WillReturnError(errTest) + + // execute and assert + res, err := exec(ctx, db, validTestTmpl, req) + require.Nil(t, res) + require.Error(t, err) + require.ErrorAs(t, err, new(SQLError)) + }) +} diff --git a/pkg/services/store/entity/sqlstash/validation.go b/pkg/services/store/entity/sqlstash/validation.go new file mode 100644 index 00000000000..b1401d37948 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/validation.go @@ -0,0 +1,22 @@ +package sqlstash + +import "github.com/grafana/grafana/pkg/services/store/entity" + +// validateEntity validates a fully loaded *entity.Entity model, and should be +// used before storing an entity to the database and before returning it to the +// user. +func validateEntity(*entity.Entity) error { + return nil // TODO +} + +// validateLabels validates the given map of label names to their values. +func validateLabels(map[string]string) error { + // this should be called by validateEntity + return nil // TODO +} + +// validateFields validates the given map of fields names to their values. +func validateFields(map[string]string) error { + // this should be called by validateEntity + return nil // TODO +} diff --git a/pkg/services/store/entity/sqlstash/validation_test.go b/pkg/services/store/entity/sqlstash/validation_test.go new file mode 100644 index 00000000000..18b9a2f3956 --- /dev/null +++ b/pkg/services/store/entity/sqlstash/validation_test.go @@ -0,0 +1,35 @@ +package sqlstash + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateEntity(t *testing.T) { + t.Parallel() + + err := validateEntity(newEmptyEntity()) + require.NoError(t, err) +} + +func TestValidateLabels(t *testing.T) { + t.Parallel() + + err := validateLabels(map[string]string{}) + require.NoError(t, err) +} + +func TestValidateFields(t *testing.T) { + t.Parallel() + + err := validateFields(map[string]string{}) + require.NoError(t, err) +} + +// Silence the `unused` linter until we implement and use these validations. +var ( + _ = validateEntity + _ = validateLabels + _ = validateFields +) diff --git a/pkg/util/testutil/context.go b/pkg/util/testutil/context.go new file mode 100644 index 00000000000..74bec2b95b4 --- /dev/null +++ b/pkg/util/testutil/context.go @@ -0,0 +1,113 @@ +package testutil + +import ( + "context" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/user" +) + +const DefaultContextTimeout = time.Second + +// TestContext is a context.Context that can be canceled with or without a +// cause. This is only relevant for testing purposes. +type TestContext interface { + context.Context + + // Cancel cancels the context. The `Err` method and the `context.Cause` + // function will return context.Canceled. + Cancel() + + // CancelCause cancels the current context with the given cause. The `Err` + // method will return context.Canceled and the `context.Cause` function will + // return the given error. + CancelCause(err error) + + // WithUser returns a derived user with the given user associated. To derive + // a context without an associated user, pass a nil value. + WithUser(*user.SignedInUser) TestContext +} + +// NewDefaultTestContext calls NewTestContext with the provided `t` and a +// timeout of DefaultContextTimeout. This should work fine for most unit tests. +func NewDefaultTestContext(t T) TestContext { + return NewTestContext(t, time.Now().Add(DefaultContextTimeout)) +} + +// NewTestCtx returns a new TestContext with the following features: +// 1. Provides a `deadline` argument which is especially useful in integration +// tests. +// 2. It honours the `-timeout` flag of `go test` if it is given, so it will +// actually timeout at the earliest deadline (either the one resulting from +// the command line or the one resulting from the `deadline` argument). +// 3. By default it has an empty user (i.e. the user at the UI login), so most +// of the code paths to be tested will not need any special setup, unless +// you need to test permissions. In that case, you can use any of the test +// users from the SignedInUser struct (all of them come from real payloads). +func NewTestContext(t T, deadline time.Time) TestContext { + t.Helper() + + // if the test has a deadline and it happens before our previously + // calculated deadline, then use it instead + if td, ok := t.Deadline(); ok && td.Before(deadline) { + deadline = td + } + + ctx, cancel := context.WithDeadline(context.Background(), deadline) + t.Cleanup(cancel) + + ctx, cancelCause := context.WithCancelCause(ctx) + + tctx := testContextFunc(func() (context.Context, context.CancelFunc, context.CancelCauseFunc) { + return ctx, cancel, cancelCause + }) + + user, err := SignedInUser{}.NewEmpty() + require.NoError(t, err) + + // TODO: improve by adding a better anonymous user struct + return tctx.WithUser(user) +} + +type testContextFunc func() (context.Context, context.CancelFunc, context.CancelCauseFunc) + +func (f testContextFunc) Deadline() (deadline time.Time, ok bool) { + ctx, _, _ := f() + return ctx.Deadline() +} + +func (f testContextFunc) Done() <-chan struct{} { + ctx, _, _ := f() + return ctx.Done() +} + +func (f testContextFunc) Err() error { + ctx, _, _ := f() + return ctx.Err() +} + +func (f testContextFunc) Value(key any) any { + ctx, _, _ := f() + return ctx.Value(key) +} + +func (f testContextFunc) Cancel() { + _, c, _ := f() + c() +} + +func (f testContextFunc) CancelCause(err error) { + _, _, cc := f() + cc(err) +} + +func (f testContextFunc) WithUser(usr *user.SignedInUser) TestContext { + ctx := appcontext.WithUser(f, usr) + + return testContextFunc(func() (context.Context, context.CancelFunc, context.CancelCauseFunc) { + return ctx, f.Cancel, f.CancelCause + }) +} diff --git a/pkg/util/testutil/context_test.go b/pkg/util/testutil/context_test.go new file mode 100644 index 00000000000..4d7ecf670f6 --- /dev/null +++ b/pkg/util/testutil/context_test.go @@ -0,0 +1,90 @@ +package testutil + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "github.com/grafana/grafana/pkg/util/testutil/mocks" +) + +func TestMain(m *testing.M) { + // make sure we don't leak goroutines after tests in this package have + // finished, which means we haven't leaked contexts either + goleak.VerifyTestMain(m) +} + +func TestTestContextFunc(t *testing.T) { + t.Parallel() + + const tolerance = 100 * time.Millisecond + + t.Run("no explicit deadline - no test deadline", func(t *testing.T) { + t.Parallel() + + tt := mocks.NewT(t) + tt.EXPECT().Helper() + tt.EXPECT().Deadline().Return(time.Time{}, false).Once() + tt.EXPECT().Cleanup(mock.Anything).Once() + + ctx := NewDefaultTestContext(tt) + d, ok := ctx.Deadline() + require.True(t, ok) + require.False(t, d.IsZero()) + diff := time.Now().Add(DefaultContextTimeout).Sub(d) + require.GreaterOrEqual(t, diff, time.Duration(0)) + require.Less(t, diff, tolerance) + + ctx.Cancel() + require.ErrorIs(t, ctx.Err(), context.Canceled) + + // already canceled, we shouldn't be able to set a cause now + ctx.CancelCause(context.DeadlineExceeded) + require.ErrorIs(t, context.Cause(ctx), context.Canceled) + + select { + case <-ctx.Done(): + default: + t.Fatalf("done channel not closed") + } + }) + + t.Run("explicit deadline - earlier test deadline", func(t *testing.T) { + t.Parallel() + + // make sure the context will be deadlined already at creation + now := time.Now().Add(-time.Second) + + tt := mocks.NewT(t) + tt.EXPECT().Helper() + tt.EXPECT().Deadline().Return(now, true).Once() + tt.EXPECT().Cleanup(mock.Anything).Once() + + ctx := NewTestContext(tt, now.Add(time.Second)) + d, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, now, d) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + }) + + t.Run("explicit deadline - later test deadline", func(t *testing.T) { + t.Parallel() + + now := time.Now().Add(-time.Second) + + tt := mocks.NewT(t) + tt.EXPECT().Helper() + tt.EXPECT().Deadline().Return(now.Add(time.Hour), true).Once() + tt.EXPECT().Cleanup(mock.Anything).Once() + + ctx := NewTestContext(tt, now) + d, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, now, d) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + }) +} diff --git a/pkg/util/testutil/data/user-anonymous.json b/pkg/util/testutil/data/user-anonymous.json new file mode 100644 index 00000000000..5f66b6a8ce5 --- /dev/null +++ b/pkg/util/testutil/data/user-anonymous.json @@ -0,0 +1,22 @@ +{ + "UserID": 0, + "UserUID": "", + "OrgID": 1, + "OrgName": "Main Org.", + "OrgRole": "Viewer", + "Login": "", + "Name": "", + "Email": "", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "", + "ApiKeyID": 0, + "IsServiceAccount": false, + "IsGrafanaAdmin": false, + "IsAnonymous": true, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "0001-01-01T00:00:00Z", + "Teams": null, + "NamespacedID": {} +} diff --git a/pkg/util/testutil/data/user-editor.json b/pkg/util/testutil/data/user-editor.json new file mode 100644 index 00000000000..a5f8a9ff422 --- /dev/null +++ b/pkg/util/testutil/data/user-editor.json @@ -0,0 +1,22 @@ +{ + "UserID": 2, + "UserUID": "ednity0wr3d34d", + "OrgID": 1, + "OrgName": "Main Org.", + "OrgRole": "Editor", + "Login": "editor", + "Name": "editor", + "Email": "editor", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "", + "ApiKeyID": 0, + "IsServiceAccount": false, + "IsGrafanaAdmin": false, + "IsAnonymous": false, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "2024-06-01T23:03:26-03:00", + "Teams": [], + "NamespacedID": {} +} diff --git a/pkg/util/testutil/data/user-empty.json b/pkg/util/testutil/data/user-empty.json new file mode 100644 index 00000000000..c95a18a78ed --- /dev/null +++ b/pkg/util/testutil/data/user-empty.json @@ -0,0 +1,22 @@ +{ + "UserID": 0, + "UserUID": "", + "OrgID": 0, + "OrgName": "", + "OrgRole": "", + "Login": "", + "Name": "", + "Email": "", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "", + "ApiKeyID": 0, + "IsServiceAccount": false, + "IsGrafanaAdmin": false, + "IsAnonymous": false, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "0001-01-01T00:00:00Z", + "Teams": null, + "NamespacedID": {} +} diff --git a/pkg/util/testutil/data/user-grafana-admin.json b/pkg/util/testutil/data/user-grafana-admin.json new file mode 100644 index 00000000000..f077d5198d8 --- /dev/null +++ b/pkg/util/testutil/data/user-grafana-admin.json @@ -0,0 +1,22 @@ +{ + "UserID": 1, + "UserUID": "", + "OrgID": 1, + "OrgName": "Main Org.", + "OrgRole": "Admin", + "Login": "admin", + "Name": "", + "Email": "admin@localhost", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "", + "ApiKeyID": 0, + "IsServiceAccount": false, + "IsGrafanaAdmin": true, + "IsAnonymous": false, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "2024-06-01T23:01:11-03:00", + "Teams": [], + "NamespacedID": {} +} diff --git a/pkg/util/testutil/data/user-service-account-viewer.json b/pkg/util/testutil/data/user-service-account-viewer.json new file mode 100644 index 00000000000..ddf17faf66c --- /dev/null +++ b/pkg/util/testutil/data/user-service-account-viewer.json @@ -0,0 +1,22 @@ +{ + "UserID": 4, + "UserUID": "fdnivb2e7c4cga", + "OrgID": 1, + "OrgName": "Main Org.", + "OrgRole": "Viewer", + "Login": "sa-1-something", + "Name": "something", + "Email": "sa-1-something", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "apikey", + "ApiKeyID": 0, + "IsServiceAccount": true, + "IsGrafanaAdmin": false, + "IsAnonymous": false, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "2024-06-01T23:20:42-03:00", + "Teams": [], + "NamespacedID": {} +} diff --git a/pkg/util/testutil/data/user-viewer.json b/pkg/util/testutil/data/user-viewer.json new file mode 100644 index 00000000000..94bd796f232 --- /dev/null +++ b/pkg/util/testutil/data/user-viewer.json @@ -0,0 +1,22 @@ +{ + "UserID": 3, + "UserUID": "fdniuj44r0zr4d", + "OrgID": 1, + "OrgName": "Main Org.", + "OrgRole": "Viewer", + "Login": "viewer", + "Name": "viewer", + "Email": "viewer", + "EmailVerified": false, + "AuthID": "", + "AuthenticatedBy": "", + "ApiKeyID": 0, + "IsServiceAccount": false, + "IsGrafanaAdmin": false, + "IsAnonymous": false, + "IsDisabled": false, + "HelpFlags1": 0, + "LastSeenAt": "2024-06-01T23:09:25-03:00", + "Teams": [], + "NamespacedID": {} +} diff --git a/pkg/util/testutil/mocks/T.go b/pkg/util/testutil/mocks/T.go new file mode 100644 index 00000000000..2e2c110fb82 --- /dev/null +++ b/pkg/util/testutil/mocks/T.go @@ -0,0 +1,232 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// T is an autogenerated mock type for the T type +type T struct { + mock.Mock +} + +type T_Expecter struct { + mock *mock.Mock +} + +func (_m *T) EXPECT() *T_Expecter { + return &T_Expecter{mock: &_m.Mock} +} + +// Cleanup provides a mock function with given fields: _a0 +func (_m *T) Cleanup(_a0 func()) { + _m.Called(_a0) +} + +// T_Cleanup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cleanup' +type T_Cleanup_Call struct { + *mock.Call +} + +// Cleanup is a helper method to define mock.On call +// - _a0 func() +func (_e *T_Expecter) Cleanup(_a0 interface{}) *T_Cleanup_Call { + return &T_Cleanup_Call{Call: _e.mock.On("Cleanup", _a0)} +} + +func (_c *T_Cleanup_Call) Run(run func(_a0 func())) *T_Cleanup_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func())) + }) + return _c +} + +func (_c *T_Cleanup_Call) Return() *T_Cleanup_Call { + _c.Call.Return() + return _c +} + +func (_c *T_Cleanup_Call) RunAndReturn(run func(func())) *T_Cleanup_Call { + _c.Call.Return(run) + return _c +} + +// Deadline provides a mock function with given fields: +func (_m *T) Deadline() (time.Time, bool) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Deadline") + } + + var r0 time.Time + var r1 bool + if rf, ok := ret.Get(0).(func() (time.Time, bool)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() time.Time); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(time.Time) + } + + if rf, ok := ret.Get(1).(func() bool); ok { + r1 = rf() + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// T_Deadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deadline' +type T_Deadline_Call struct { + *mock.Call +} + +// Deadline is a helper method to define mock.On call +func (_e *T_Expecter) Deadline() *T_Deadline_Call { + return &T_Deadline_Call{Call: _e.mock.On("Deadline")} +} + +func (_c *T_Deadline_Call) Run(run func()) *T_Deadline_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *T_Deadline_Call) Return(_a0 time.Time, _a1 bool) *T_Deadline_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *T_Deadline_Call) RunAndReturn(run func() (time.Time, bool)) *T_Deadline_Call { + _c.Call.Return(run) + return _c +} + +// Errorf provides a mock function with given fields: format, args +func (_m *T) Errorf(format string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// T_Errorf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Errorf' +type T_Errorf_Call struct { + *mock.Call +} + +// Errorf is a helper method to define mock.On call +// - format string +// - args ...interface{} +func (_e *T_Expecter) Errorf(format interface{}, args ...interface{}) *T_Errorf_Call { + return &T_Errorf_Call{Call: _e.mock.On("Errorf", + append([]interface{}{format}, args...)...)} +} + +func (_c *T_Errorf_Call) Run(run func(format string, args ...interface{})) *T_Errorf_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *T_Errorf_Call) Return() *T_Errorf_Call { + _c.Call.Return() + return _c +} + +func (_c *T_Errorf_Call) RunAndReturn(run func(string, ...interface{})) *T_Errorf_Call { + _c.Call.Return(run) + return _c +} + +// FailNow provides a mock function with given fields: +func (_m *T) FailNow() { + _m.Called() +} + +// T_FailNow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FailNow' +type T_FailNow_Call struct { + *mock.Call +} + +// FailNow is a helper method to define mock.On call +func (_e *T_Expecter) FailNow() *T_FailNow_Call { + return &T_FailNow_Call{Call: _e.mock.On("FailNow")} +} + +func (_c *T_FailNow_Call) Run(run func()) *T_FailNow_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *T_FailNow_Call) Return() *T_FailNow_Call { + _c.Call.Return() + return _c +} + +func (_c *T_FailNow_Call) RunAndReturn(run func()) *T_FailNow_Call { + _c.Call.Return(run) + return _c +} + +// Helper provides a mock function with given fields: +func (_m *T) Helper() { + _m.Called() +} + +// T_Helper_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Helper' +type T_Helper_Call struct { + *mock.Call +} + +// Helper is a helper method to define mock.On call +func (_e *T_Expecter) Helper() *T_Helper_Call { + return &T_Helper_Call{Call: _e.mock.On("Helper")} +} + +func (_c *T_Helper_Call) Run(run func()) *T_Helper_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *T_Helper_Call) Return() *T_Helper_Call { + _c.Call.Return() + return _c +} + +func (_c *T_Helper_Call) RunAndReturn(run func()) *T_Helper_Call { + _c.Call.Return(run) + return _c +} + +// NewT creates a new instance of T. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewT(t interface { + mock.TestingT + Cleanup(func()) +}) *T { + mock := &T{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/util/testutil/testutil.go b/pkg/util/testutil/testutil.go new file mode 100644 index 00000000000..1ca3628a665 --- /dev/null +++ b/pkg/util/testutil/testutil.go @@ -0,0 +1,30 @@ +package testutil + +import ( + "embed" + "testing" + "time" +) + +//go:embed data/* +var dataFS embed.FS + +//go:generate mockery --with-expecter --name T + +// T provides a clean way to test the utilities of this package. +type T interface { + Helper() + Cleanup(func()) + Deadline() (time.Time, bool) + Errorf(format string, args ...any) + FailNow() +} + +func init() { + // At the moment of this writing, there is already testing code imported in + // server runtime code. Please, consider refactoring your code to keep + // runtime dependencies clean. + if !testing.Testing() { + panic("importing testing libraries in runtime code is not allowed") + } +} diff --git a/pkg/util/testutil/user.go b/pkg/util/testutil/user.go new file mode 100644 index 00000000000..d08bcd485b9 --- /dev/null +++ b/pkg/util/testutil/user.go @@ -0,0 +1,43 @@ +package testutil + +import ( + "encoding/json" + + "github.com/grafana/grafana/pkg/services/user" +) + +type SignedInUser struct{} + +func (SignedInUser) NewAnonymous() (*user.SignedInUser, error) { + return readUser(`user-anonymous.json`) +} + +func (SignedInUser) NewEditor() (*user.SignedInUser, error) { + return readUser(`user-editor.json`) +} + +func (SignedInUser) NewGrafanaAdmin() (*user.SignedInUser, error) { + return readUser(`user-grafana-admin.json`) +} + +func (SignedInUser) NewEmpty() (*user.SignedInUser, error) { + return readUser(`user-empty.json`) +} + +func (SignedInUser) NewServiceAccount() (*user.SignedInUser, error) { + return readUser(`user-service-account-viewer.json`) +} + +func (SignedInUser) NewViewer() (*user.SignedInUser, error) { + return readUser(`user-viewer.json`) +} + +func readUser(filename string) (*user.SignedInUser, error) { + file, err := dataFS.Open(`data/` + filename) + if err != nil { + return nil, err + } + ret := new(user.SignedInUser) + + return ret, json.NewDecoder(file).Decode(ret) +} diff --git a/pkg/util/testutil/user_test.go b/pkg/util/testutil/user_test.go new file mode 100644 index 00000000000..4271987263f --- /dev/null +++ b/pkg/util/testutil/user_test.go @@ -0,0 +1,29 @@ +package testutil + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/user" +) + +func TestSignedInUser(t *testing.T) { + t.Parallel() + + r := func(user *user.SignedInUser, err error) { + require.NotNil(t, user) + require.NoError(t, err) + } + + r(SignedInUser{}.NewAnonymous()) + r(SignedInUser{}.NewEditor()) + r(SignedInUser{}.NewGrafanaAdmin()) + r(SignedInUser{}.NewEmpty()) + r(SignedInUser{}.NewServiceAccount()) + r(SignedInUser{}.NewViewer()) + + user, err := readUser(`non existent!!!`) + require.Nil(t, user) + require.Error(t, err) +}