mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Unified Storage: Fix Create, Update and Delete wrt Resource Versions (#88183)
* add sqltemplate utilities, improve tests and documentation * bunch of things * remove unnecessary message * add queries * add queries * add queries * add folders support * fix diff * fix linters * fix diff * fix linters * fix linters * fix typo * fix linters * fix linters * fix linters * several fixes * several fixes * temporarily disable k8s integration tests for Entity Server * postpone some tests * postpone documentation changes * Fix bug in create * improve error reporting * fix PostgeSQL parameters * fix MySQL sqlmode * fix MySQL-5.7 * reduce but document the number of database connection options * remove unused code and improve docs
This commit is contained in:
parent
cbe7521a56
commit
6fcd7d9e03
@ -99,6 +99,7 @@ type service struct {
|
||||
cfg *setting.Cfg
|
||||
features featuremgmt.FeatureToggles
|
||||
|
||||
startedCh chan struct{}
|
||||
stopCh chan struct{}
|
||||
stoppedCh chan error
|
||||
|
||||
@ -124,6 +125,7 @@ func ProvideService(
|
||||
cfg: cfg,
|
||||
features: features,
|
||||
rr: rr,
|
||||
startedCh: make(chan struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
builders: []builder.APIGroupBuilder{},
|
||||
authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService),
|
||||
@ -139,6 +141,7 @@ func ProvideService(
|
||||
// the routes are registered before the Grafana HTTP server starts.
|
||||
proxyHandler := func(k8sRoute routing.RouteRegister) {
|
||||
handler := func(c *contextmodel.ReqContext) {
|
||||
<-s.startedCh
|
||||
if s.handler == nil {
|
||||
c.Resp.WriteHeader(404)
|
||||
_, _ = c.Resp.Write([]byte("Not found"))
|
||||
@ -188,6 +191,8 @@ func (s *service) RegisterAPI(b builder.APIGroupBuilder) {
|
||||
}
|
||||
|
||||
func (s *service) start(ctx context.Context) error {
|
||||
defer close(s.startedCh)
|
||||
|
||||
// Get the list of groups the server will support
|
||||
builders := s.builders
|
||||
|
||||
@ -405,6 +410,7 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
|
||||
return &clientrest.Config{
|
||||
Transport: &roundTripperFunc{
|
||||
fn: func(req *http.Request) (*http.Response, error) {
|
||||
<-s.startedCh
|
||||
ctx := appcontext.WithUser(req.Context(), c.SignedInUser)
|
||||
wrapped := grafanaresponsewriter.WrapHandler(s.handler)
|
||||
return wrapped(req.WithContext(ctx))
|
||||
@ -414,6 +420,7 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
|
||||
}
|
||||
|
||||
func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
<-s.startedCh
|
||||
s.handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
|
@ -153,6 +153,7 @@ func TestIntegrationWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -164,6 +165,7 @@ func TestIntegrationClusterScopedWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -175,6 +177,7 @@ func TestIntegrationNamespaceScopedWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -186,6 +189,7 @@ func TestIntegrationDeleteTriggerWatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -197,6 +201,7 @@ func TestIntegrationWatchFromZero(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -210,6 +215,7 @@ func TestIntegrationWatchFromNonZero(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -255,6 +261,7 @@ func TestIntegrationWatcherTimeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -266,6 +273,7 @@ func TestIntegrationWatchDeleteEventObjectHaveLatestRV(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -303,6 +311,7 @@ func TestIntegrationWatchDispatchBookmarkEvents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
@ -326,6 +335,7 @@ func TestIntegrationEtcdWatchSemantics(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
t.Skip("In maintenance")
|
||||
|
||||
ctx, store, destroyFunc, err := testSetup(t)
|
||||
defer destroyFunc()
|
||||
|
@ -121,6 +121,7 @@ func (m *grafanaResourceMetaAccessor) GetUpdatedTimestamp() (*time.Time, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error())
|
||||
}
|
||||
t = t.UTC()
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
@ -195,7 +196,7 @@ func (m *grafanaResourceMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) {
|
||||
anno[AnnoKeyOriginKey] = info.Key
|
||||
}
|
||||
if info.Timestamp != nil {
|
||||
anno[AnnoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339)
|
||||
anno[AnnoKeyOriginTimestamp] = info.Timestamp.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
m.obj.SetAnnotations(anno)
|
||||
|
@ -8,6 +8,9 @@ import (
|
||||
|
||||
// Really just spitballing here :) this should hook into a system that can give better display info
|
||||
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.?
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
|
@ -1,75 +1,105 @@
|
||||
package dbimpl
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
)
|
||||
|
||||
func getEngineMySQL(cfgSection *setting.DynamicSection, tracer tracing.Tracer) (*xorm.Engine, error) {
|
||||
dbHost := cfgSection.Key("db_host").MustString("")
|
||||
dbName := cfgSection.Key("db_name").MustString("")
|
||||
dbUser := cfgSection.Key("db_user").MustString("")
|
||||
dbPass := cfgSection.Key("db_pass").MustString("")
|
||||
func getEngineMySQL(getter *sectionGetter, _ tracing.Tracer) (*xorm.Engine, error) {
|
||||
config := mysql.NewConfig()
|
||||
config.User = getter.String("db_user")
|
||||
config.Passwd = getter.String("db_pass")
|
||||
config.Net = "tcp"
|
||||
config.Addr = getter.String("db_host")
|
||||
config.DBName = getter.String("db_name")
|
||||
config.Params = map[string]string{
|
||||
// See: https://dev.mysql.com/doc/refman/en/sql-mode.html
|
||||
"@@SESSION.sql_mode": "ANSI",
|
||||
}
|
||||
config.Collation = "utf8mb4_unicode_ci"
|
||||
config.Loc = time.UTC
|
||||
config.AllowNativePasswords = true
|
||||
config.ClientFoundRows = true
|
||||
|
||||
// TODO: support all mysql connection options
|
||||
protocol := "tcp"
|
||||
if strings.HasPrefix(dbHost, "/") {
|
||||
protocol = "unix"
|
||||
// TODO: do we want to support these?
|
||||
// config.ServerPubKey = getter.String("db_server_pub_key")
|
||||
// config.TLSConfig = getter.String("db_tls_config_name")
|
||||
|
||||
if err := getter.Err(); err != nil {
|
||||
return nil, fmt.Errorf("config error: %w", err)
|
||||
}
|
||||
|
||||
connectionString := connectionStringMySQL(dbUser, dbPass, protocol, dbHost, dbName)
|
||||
if strings.HasPrefix(config.Addr, "/") {
|
||||
config.Net = "unix"
|
||||
}
|
||||
|
||||
driverName := sqlstore.WrapDatabaseDriverWithHooks("mysql", tracer)
|
||||
engine, err := xorm.NewEngine(driverName, connectionString)
|
||||
// FIXME: get rid of xorm
|
||||
engine, err := xorm.NewEngine(db.DriverMySQL, config.FormatDSN())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
engine.SetMaxOpenConns(0)
|
||||
engine.SetMaxIdleConns(2)
|
||||
engine.SetConnMaxLifetime(time.Second * time.Duration(14400))
|
||||
engine.SetConnMaxLifetime(4 * time.Hour)
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func getEnginePostgres(cfgSection *setting.DynamicSection, tracer tracing.Tracer) (*xorm.Engine, error) {
|
||||
dbHost := cfgSection.Key("db_host").MustString("")
|
||||
dbName := cfgSection.Key("db_name").MustString("")
|
||||
dbUser := cfgSection.Key("db_user").MustString("")
|
||||
dbPass := cfgSection.Key("db_pass").MustString("")
|
||||
|
||||
// TODO: support all postgres connection options
|
||||
dbSslMode := cfgSection.Key("db_sslmode").MustString("disable")
|
||||
|
||||
addr, err := util.SplitHostPortDefault(dbHost, "127.0.0.1", "5432")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid host specifier '%s': %w", dbHost, err)
|
||||
func getEnginePostgres(getter *sectionGetter, _ tracing.Tracer) (*xorm.Engine, error) {
|
||||
dsnKV := map[string]string{
|
||||
"user": getter.String("db_user"),
|
||||
"password": getter.String("db_pass"),
|
||||
"dbname": getter.String("db_name"),
|
||||
"sslmode": cmp.Or(getter.String("db_sslmode"), "disable"),
|
||||
}
|
||||
|
||||
connectionString := connectionStringPostgres(dbUser, dbPass, addr.Host, addr.Port, dbName, dbSslMode)
|
||||
// TODO: probably interesting:
|
||||
// "passfile", "statement_timeout", "lock_timeout", "connect_timeout"
|
||||
|
||||
driverName := sqlstore.WrapDatabaseDriverWithHooks("postgres", tracer)
|
||||
engine, err := xorm.NewEngine(driverName, connectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// TODO: for CockroachDB, we probably need to use the following:
|
||||
// dsnKV["options"] = "-c enable_experimental_alter_column_type_general=true"
|
||||
// Or otherwise specify it as:
|
||||
// dsnKV["enable_experimental_alter_column_type_general"] = "true"
|
||||
|
||||
// TODO: do we want to support these options in the DSN as well?
|
||||
// "sslkey", "sslcert", "sslrootcert", "sslpassword", "sslsni", "krbspn",
|
||||
// "krbsrvname", "target_session_attrs", "service", "servicefile"
|
||||
|
||||
// More on Postgres connection string parameters:
|
||||
// https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
|
||||
|
||||
hostport := getter.String("db_host")
|
||||
|
||||
if err := getter.Err(); err != nil {
|
||||
return nil, fmt.Errorf("config error: %w", err)
|
||||
}
|
||||
|
||||
host, port, err := splitHostPortDefault(hostport, "127.0.0.1", "5432")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid db_host: %w", err)
|
||||
}
|
||||
dsnKV["host"] = host
|
||||
dsnKV["port"] = port
|
||||
|
||||
dsn, err := MakeDSN(dsnKV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building DSN: %w", err)
|
||||
}
|
||||
|
||||
// FIXME: get rid of xorm
|
||||
engine, err := xorm.NewEngine(db.DriverPostgres, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func connectionStringMySQL(user, password, protocol, host, dbName string) string {
|
||||
return fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", user, password, protocol, host, dbName)
|
||||
}
|
||||
|
||||
func connectionStringPostgres(user, password, host, port, dbName, sslMode string) string {
|
||||
return fmt.Sprintf(
|
||||
"user=%s password=%s host=%s port=%s dbname=%s sslmode=%s", // sslcert='%s' sslkey='%s' sslrootcert='%s'",
|
||||
user, password, host, port, dbName, sslMode, // ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath
|
||||
)
|
||||
}
|
||||
|
@ -19,7 +19,10 @@ func TestGetEnginePostgresFromConfig(t *testing.T) {
|
||||
s.Key("db_user").SetValue("user")
|
||||
s.Key("db_password").SetValue("password")
|
||||
|
||||
engine, err := getEnginePostgres(cfg.SectionWithEnvOverrides("entity_api"), nil)
|
||||
getter := §ionGetter{
|
||||
DynamicSection: cfg.SectionWithEnvOverrides("entity_api"),
|
||||
}
|
||||
engine, err := getEnginePostgres(getter, nil)
|
||||
|
||||
assert.NotNil(t, engine)
|
||||
assert.NoError(t, err)
|
||||
@ -36,19 +39,11 @@ func TestGetEngineMySQLFromConfig(t *testing.T) {
|
||||
s.Key("db_user").SetValue("user")
|
||||
s.Key("db_password").SetValue("password")
|
||||
|
||||
engine, err := getEngineMySQL(cfg.SectionWithEnvOverrides("entity_api"), nil)
|
||||
getter := §ionGetter{
|
||||
DynamicSection: cfg.SectionWithEnvOverrides("entity_api"),
|
||||
}
|
||||
engine, err := getEngineMySQL(getter, nil)
|
||||
|
||||
assert.NotNil(t, engine)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetConnectionStrings(t *testing.T) {
|
||||
t.Run("generate mysql connection string", func(t *testing.T) {
|
||||
expected := "user:password@tcp(localhost)/grafana?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true"
|
||||
assert.Equal(t, expected, connectionStringMySQL("user", "password", "tcp", "localhost", "grafana"))
|
||||
})
|
||||
t.Run("generate postgres connection string", func(t *testing.T) {
|
||||
expected := "user=user password=password host=localhost port=5432 dbname=grafana sslmode=disable"
|
||||
assert.Equal(t, expected, connectionStringPostgres("user", "password", "localhost", "5432", "grafana", "disable"))
|
||||
})
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package dbimpl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/dlmiddlecote/sqlstats"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@ -31,6 +32,9 @@ func ProvideEntityDB(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureTog
|
||||
}
|
||||
|
||||
type EntityDB struct {
|
||||
once sync.Once
|
||||
onceErr error
|
||||
|
||||
db db.DB
|
||||
features featuremgmt.FeatureToggles
|
||||
engine *xorm.Engine
|
||||
@ -45,42 +49,57 @@ func (db *EntityDB) Init() error {
|
||||
}
|
||||
|
||||
func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
|
||||
db.once.Do(func() {
|
||||
db.onceErr = db.init()
|
||||
})
|
||||
|
||||
return db.engine, db.onceErr
|
||||
}
|
||||
|
||||
func (db *EntityDB) init() error {
|
||||
if db.engine != nil {
|
||||
return db.engine, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var engine *xorm.Engine
|
||||
var err error
|
||||
|
||||
cfgSection := db.cfg.SectionWithEnvOverrides("entity_api")
|
||||
dbType := cfgSection.Key("db_type").MustString("")
|
||||
getter := §ionGetter{
|
||||
DynamicSection: db.cfg.SectionWithEnvOverrides("entity_api"),
|
||||
}
|
||||
|
||||
dbType := getter.Key("db_type").MustString("")
|
||||
|
||||
// if explicit connection settings are provided, use them
|
||||
if dbType != "" {
|
||||
if dbType == "postgres" {
|
||||
engine, err = getEnginePostgres(cfgSection, db.tracer)
|
||||
engine, err = getEnginePostgres(getter, db.tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
// FIXME: this config option is cockroachdb-specific, it's not supported by postgres
|
||||
// FIXME: this only sets this option for the session that we get
|
||||
// from the pool right now. A *sql.DB is a pool of connections,
|
||||
// there is no guarantee that the session where this is run will be
|
||||
// the same where we need to change the type of a column
|
||||
_, err = engine.Exec("SET SESSION enable_experimental_alter_column_type_general=true")
|
||||
if err != nil {
|
||||
db.log.Error("error connecting to postgres", "msg", err.Error())
|
||||
// FIXME: return nil, err
|
||||
}
|
||||
} else if dbType == "mysql" {
|
||||
engine, err = getEngineMySQL(cfgSection, db.tracer)
|
||||
engine, err = getEngineMySQL(getter, db.tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
_, err = engine.Exec("SELECT 1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
if err = engine.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// TODO: sqlite support
|
||||
return nil, fmt.Errorf("invalid db type specified: %s", dbType)
|
||||
return fmt.Errorf("invalid db type specified: %s", dbType)
|
||||
}
|
||||
|
||||
// register sql stat metrics
|
||||
@ -89,7 +108,7 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
|
||||
}
|
||||
|
||||
// configure sql logging
|
||||
debugSQL := cfgSection.Key("log_queries").MustBool(false)
|
||||
debugSQL := getter.Key("log_queries").MustBool(false)
|
||||
if !debugSQL {
|
||||
engine.SetLogger(&xorm.DiscardLogger{})
|
||||
} else {
|
||||
@ -98,10 +117,11 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
|
||||
engine.ShowSQL(true)
|
||||
engine.ShowExecTime(true)
|
||||
}
|
||||
|
||||
// otherwise, try to use the grafana db connection
|
||||
} else {
|
||||
if db.db == nil {
|
||||
return nil, fmt.Errorf("no db connection provided")
|
||||
return fmt.Errorf("no db connection provided")
|
||||
}
|
||||
|
||||
engine = db.db.GetEngine()
|
||||
@ -109,12 +129,12 @@ func (db *EntityDB) GetEngine() (*xorm.Engine, error) {
|
||||
|
||||
db.engine = engine
|
||||
|
||||
if err := migrations.MigrateEntityStore(db, db.features); err != nil {
|
||||
if err := migrations.MigrateEntityStore(engine, db.cfg, db.features); err != nil {
|
||||
db.engine = nil
|
||||
return nil, err
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return db.engine, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *EntityDB) GetSession() (*session.SessionDB, error) {
|
||||
|
99
pkg/services/store/entity/db/dbimpl/util.go
Normal file
99
pkg/services/store/entity/db/dbimpl/util.go
Normal file
@ -0,0 +1,99 @@
|
||||
package dbimpl
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidUTF8Sequence = errors.New("invalid UTF-8 sequence")
|
||||
)
|
||||
|
||||
type sectionGetter struct {
|
||||
*setting.DynamicSection
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *sectionGetter) Err() error {
|
||||
return g.err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// MakeDSN creates a DSN from the given key/value pair. It validates the strings
|
||||
// form valid UTF-8 sequences and escapes values if needed.
|
||||
func MakeDSN(m map[string]string) (string, error) {
|
||||
b := new(strings.Builder)
|
||||
|
||||
for k, v := range m {
|
||||
if !utf8.ValidString(v) {
|
||||
return "", fmt.Errorf("value for DSN key %q: %w", k,
|
||||
ErrInvalidUTF8Sequence)
|
||||
}
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if b.Len() > 0 {
|
||||
_ = b.WriteByte(' ')
|
||||
}
|
||||
_, _ = b.WriteString(k)
|
||||
_ = b.WriteByte('=')
|
||||
writeDSNValue(b, v)
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func writeDSNValue(b *strings.Builder, v string) {
|
||||
numq := strings.Count(v, `'`)
|
||||
numb := strings.Count(v, `\`)
|
||||
if numq+numb == 0 && v != "" {
|
||||
b.WriteString(v)
|
||||
|
||||
return
|
||||
}
|
||||
b.Grow(2 + numq + numb + len(v))
|
||||
|
||||
_ = b.WriteByte('\'')
|
||||
for _, r := range v {
|
||||
if r == '\\' || r == '\'' {
|
||||
_ = b.WriteByte('\\')
|
||||
}
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
_ = b.WriteByte('\'')
|
||||
}
|
||||
|
||||
func splitHostPortDefault(hostport, defaultHost, defaultPort string) (string, string, error) {
|
||||
host, port, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
// try appending the port
|
||||
host, port, err = net.SplitHostPort(hostport + ":" + defaultPort)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid hostport: %q", hostport)
|
||||
}
|
||||
}
|
||||
host = cmp.Or(host, defaultHost)
|
||||
port = cmp.Or(port, defaultPort)
|
||||
|
||||
return host, port, nil
|
||||
}
|
@ -1,23 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func MigrateEntityStore(db db.EntityDBInterface, features featuremgmt.FeatureToggles) error {
|
||||
func MigrateEntityStore(engine *xorm.Engine, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error {
|
||||
// Skip if feature flag is not enabled
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine, err := db.GetEngine()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mg := migrator.NewScopedMigrator(engine, db.GetCfg(), "entity")
|
||||
mg := migrator.NewScopedMigrator(engine, cfg, "entity")
|
||||
mg.AddCreateMigration()
|
||||
|
||||
initEntityTables(mg)
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
const (
|
||||
DriverPostgres = "postgres"
|
||||
DriverMySQL = "mysql"
|
||||
DriverSQLite = "sqlite"
|
||||
DriverSQLite3 = "sqlite3"
|
||||
)
|
||||
|
||||
@ -40,17 +41,29 @@ type DB interface {
|
||||
DriverName() string
|
||||
}
|
||||
|
||||
// TxFunc is a function that executes with access to a transaction. The context
|
||||
// it receives is the same context used to create the transaction, and is
|
||||
// provided so that a general prupose TxFunc is able to retrieve information
|
||||
// from that context, and derive other contexts that may be used to run database
|
||||
// operation methods accepting a context. A derived context can be used to
|
||||
// request a specific database operation to take no more than a specific
|
||||
// fraction of the remaining timeout of the transaction context, or to enrich
|
||||
// the downstream observability layer with relevant information regarding the
|
||||
// specific operation being carried out.
|
||||
type TxFunc = func(context.Context, Tx) error
|
||||
|
||||
// Tx is a thin abstraction on *sql.Tx to allow mocking to provide better unit
|
||||
// testing. We allow database operation methods that do not take a
|
||||
// context.Context here since a Tx can only be obtained with DB.BeginTx, which
|
||||
// already takes a context.Context.
|
||||
type Tx interface {
|
||||
ContextExecer
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
Commit() error
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
// ContextExecer is a set of database operation methods that take
|
||||
// context.Context.
|
||||
type ContextExecer interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
|
@ -3,6 +3,7 @@ package entity
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -120,17 +121,22 @@ func (s *entityStoreStub) FindReferences(ctx context.Context, r *ReferenceReques
|
||||
}
|
||||
|
||||
type fakeHealthWatchServer struct {
|
||||
mu sync.Mutex
|
||||
grpc.ServerStream
|
||||
healthChecks []*grpc_health_v1.HealthCheckResponse
|
||||
context context.Context
|
||||
}
|
||||
|
||||
func (f *fakeHealthWatchServer) Send(resp *grpc_health_v1.HealthCheckResponse) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.healthChecks = append(f.healthChecks, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeHealthWatchServer) RecvMsg(m interface{}) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.healthChecks) == 0 {
|
||||
return errors.New("no health checks received")
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadca
|
||||
}
|
||||
|
||||
type broadcaster[T any] struct {
|
||||
running bool
|
||||
running bool // FIXME: race condition between `Subscribe`/`Unsubscribe` and `start`
|
||||
ctx context.Context
|
||||
subs map[chan T]struct{}
|
||||
cache Cache[T]
|
||||
|
129
pkg/services/store/entity/sqlstash/create.go
Normal file
129
pkg/services/store/entity/sqlstash/create.go
Normal file
@ -0,0 +1,129 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequest) (*entity.CreateEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Create")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Entity.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
// validate and process the request to get the information we need to run
|
||||
// the query
|
||||
newEntity, err := entityForCreate(ctx, r, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entity: entity from create entity request: %w", err)
|
||||
}
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
if len(newEntity.Entity.Labels) > 0 {
|
||||
// Pre-locking: register this entity's labels
|
||||
insLabels := sqlEntityLabelsInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: newEntity.Guid,
|
||||
Labels: newEntity.Entity.Labels,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabels); err != nil {
|
||||
return fmt.Errorf("insert into entity_labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newEntity.ResourceVersion = newVersion
|
||||
|
||||
// 2. Insert into entity
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
TableEntity: true,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity: %w", err)
|
||||
}
|
||||
|
||||
// 3. Insert into entity history
|
||||
insEntityHistory := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntityHistory); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// 4. Rebuild the whole folder tree structure if we're creating a folder
|
||||
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we define the "Error" field here and how? (i.e. how
|
||||
// to determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("create entity: %w", err)
|
||||
}
|
||||
|
||||
return &entity.CreateEntityResponse{
|
||||
Entity: newEntity.Entity,
|
||||
Status: entity.CreateEntityResponse_CREATED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// entityForCreate validates the given request and returns a *returnsEntity
|
||||
// populated accordingly.
|
||||
func entityForCreate(ctx context.Context, r *entity.CreateEntityRequest, key *entity.Key) (*returnsEntity, error) {
|
||||
newEntity := &returnsEntity{
|
||||
Entity: cloneEntity(r.Entity),
|
||||
}
|
||||
if err := newEntity.marshal(); err != nil {
|
||||
return nil, fmt.Errorf("serialize entity data for db: %w", err)
|
||||
}
|
||||
|
||||
createdAt := time.Now().UnixMilli()
|
||||
createdBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newEntity.Guid = uuid.New().String()
|
||||
|
||||
newEntity.Group = key.Group
|
||||
newEntity.Resource = key.Resource
|
||||
newEntity.Namespace = key.Namespace
|
||||
newEntity.Name = key.Name
|
||||
|
||||
newEntity.Size = int64(len(r.Entity.Body))
|
||||
newEntity.ETag = createETag(r.Entity.Body, r.Entity.Meta, r.Entity.Status)
|
||||
|
||||
newEntity.CreatedAt = createdAt
|
||||
newEntity.CreatedBy = createdBy
|
||||
newEntity.UpdatedAt = createdAt
|
||||
newEntity.UpdatedBy = createdBy
|
||||
|
||||
newEntity.Action = entity.Entity_CREATED
|
||||
|
||||
return newEntity, nil
|
||||
}
|
61
pkg/services/store/entity/sqlstash/data/common.sql
Normal file
61
pkg/services/store/entity/sqlstash/data/common.sql
Normal file
@ -0,0 +1,61 @@
|
||||
{{/*
|
||||
This is the list of all the fields in *entity.Entity, in a way that is
|
||||
suitable to be imported by other templates that need to select these fields
|
||||
from either the "entity" or the "entity_history" tables.
|
||||
|
||||
Example usage:
|
||||
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
FROM {{ .Ident "entity" }} AS e
|
||||
|
||||
*/}}
|
||||
{{ define "common_entity_select_into" }}
|
||||
|
||||
e.{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
e.{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
e.{{ .Ident "group" | .Into .Entity.Group }},
|
||||
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
e.{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
e.{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
e.{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
e.{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
e.{{ .Ident "body" | .Into .Entity.Body }},
|
||||
e.{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
e.{{ .Ident "size" | .Into .Entity.Size }},
|
||||
e.{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
e.{{ .Ident "title" | .Into .Entity.Title }},
|
||||
e.{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
e.{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
e.{{ .Ident "message" | .Into .Entity.Message }},
|
||||
e.{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
e.{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
e.{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
e.{{ .Ident "action" | .Into .Entity.Action }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Build an ORDER BY clause from a []SortBy contained in a .Sort field */}}
|
||||
{{ define "common_order_by" }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range .Sort }}
|
||||
{{- call $comma -}} {{ $.Ident .Field }} {{ .Direction.String }}
|
||||
{{ end }}
|
||||
{{ end }}
|
@ -12,24 +12,18 @@ INSERT INTO {{ .Ident "entity_folder" }}
|
||||
)
|
||||
|
||||
VALUES
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range .Items }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
(
|
||||
{{ $this.Arg .GUID }},
|
||||
{{ $this.Arg .Namespace }},
|
||||
{{ $this.Arg .UID }},
|
||||
{{ $this.Arg .SlugPath }},
|
||||
{{ $this.Arg .JS }},
|
||||
{{ $this.Arg .Depth }},
|
||||
{{ $this.Arg .Left }},
|
||||
{{ $this.Arg .Right }},
|
||||
{{ $this.Arg .Detached }}
|
||||
{{- call $comma -}} (
|
||||
{{ $.Arg .GUID }},
|
||||
{{ $.Arg .Namespace }},
|
||||
{{ $.Arg .UID }},
|
||||
{{ $.Arg .SlugPath }},
|
||||
{{ $.Arg .JS }},
|
||||
{{ $.Arg .Depth }},
|
||||
{{ $.Arg .Left }},
|
||||
{{ $.Arg .Right }},
|
||||
{{ $.Arg .Detached }}
|
||||
)
|
||||
{{ end }}
|
||||
;
|
||||
|
30
pkg/services/store/entity/sqlstash/data/entity_history.sql
Normal file
30
pkg/services/store/entity/sqlstash/data/entity_history.sql
Normal file
@ -0,0 +1,30 @@
|
||||
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 }}
|
||||
;
|
@ -2,17 +2,6 @@ DELETE FROM {{ .Ident "entity_labels" }}
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "guid" }} = {{ .Arg .GUID }}
|
||||
{{ if gt (len .KeepLabels) 0 }}
|
||||
AND {{ .Ident "label" }} NOT IN (
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ range .KeepLabels }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
{{ $this.Arg . }}
|
||||
{{ end }}
|
||||
)
|
||||
AND {{ .Ident "label" }} NOT IN ( {{ .ArgList .KeepLabels }} )
|
||||
{{ end }}
|
||||
;
|
||||
|
@ -6,24 +6,12 @@ INSERT INTO {{ .Ident "entity_labels" }}
|
||||
)
|
||||
|
||||
VALUES
|
||||
{{/*
|
||||
When we enter the "range" loop the "." will be changed, so we need to
|
||||
store the current ".GUID" in a variable to be able to use its value
|
||||
*/}}
|
||||
{{ $guid := .GUID }}
|
||||
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range $name, $value := .Labels }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
(
|
||||
{{ $this.Arg $guid }},
|
||||
{{ $this.Arg $name }},
|
||||
{{ $this.Arg $value }}
|
||||
{{- call $comma -}} (
|
||||
{{ $.Arg $.GUID }},
|
||||
{{ $.Arg $name }},
|
||||
{{ $.Arg $value }}
|
||||
)
|
||||
{{ end }}
|
||||
;
|
||||
|
@ -1,49 +1,10 @@
|
||||
SELECT
|
||||
{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
{{ .Ident "group" | .Into .Entity.Group }},
|
||||
{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
{{ .Ident "body" | .Into .Entity.Body }},
|
||||
{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
{{ .Ident "size" | .Into .Entity.Size }},
|
||||
{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
{{ .Ident "title" | .Into .Entity.Title }},
|
||||
{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
{{ .Ident "message" | .Into .Entity.Message }},
|
||||
{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
{{ .Ident "action" | .Into .Entity.Action }}
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
|
||||
FROM
|
||||
{{ if gt .ResourceVersion 0 }}
|
||||
{{ .Ident "entity_history" }}
|
||||
{{ .Ident "entity_history" }} AS e
|
||||
{{ else }}
|
||||
{{ .Ident "entity" }}
|
||||
{{ .Ident "entity" }} AS e
|
||||
{{ end }}
|
||||
|
||||
WHERE 1 = 1
|
||||
@ -73,6 +34,6 @@ SELECT
|
||||
{{ end }}
|
||||
|
||||
{{ if .SelectForUpdate }}
|
||||
{{ .SelectFor "UPDATE" }}
|
||||
{{ .SelectFor "UPDATE NOWAIT" }}
|
||||
{{ end }}
|
||||
;
|
||||
|
@ -1,43 +1,4 @@
|
||||
SELECT
|
||||
e.{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
e.{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
e.{{ .Ident "group" | .Into .Entity.Group }},
|
||||
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
e.{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
e.{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
e.{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
e.{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
e.{{ .Ident "body" | .Into .Entity.Body }},
|
||||
e.{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
e.{{ .Ident "size" | .Into .Entity.Size }},
|
||||
e.{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
e.{{ .Ident "title" | .Into .Entity.Title }},
|
||||
e.{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
e.{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
e.{{ .Ident "message" | .Into .Entity.Message }},
|
||||
e.{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
e.{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
e.{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
e.{{ .Ident "action" | .Into .Entity.Action }}
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
|
||||
FROM
|
||||
{{ .Ident "entity_ref" }} AS r
|
||||
|
10
pkg/services/store/entity/sqlstash/data/kind_version_get.sql
Normal file
10
pkg/services/store/entity/sqlstash/data/kind_version_get.sql
Normal file
@ -0,0 +1,10 @@
|
||||
SELECT
|
||||
{{ .Ident "resource_version" | .Into .ResourceVersion }},
|
||||
{{ .Ident "created_at" | .Into .ResourceVersion }},
|
||||
{{ .Ident "updated_at" | .Into .ResourceVersion }}
|
||||
|
||||
FROM {{ .Ident "kind_version" }}
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
;
|
@ -1,5 +1,8 @@
|
||||
UPDATE {{ .Ident "kind_version" }}
|
||||
SET {{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1
|
||||
SET
|
||||
{{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1,
|
||||
{{ .Ident "updated_at" }} = {{ .Arg .UpdatedAt }}
|
||||
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
|
@ -2,12 +2,16 @@ INSERT INTO {{ .Ident "kind_version" }}
|
||||
(
|
||||
{{ .Ident "group" }},
|
||||
{{ .Ident "resource" }},
|
||||
{{ .Ident "resource_version" }}
|
||||
{{ .Ident "resource_version" }},
|
||||
{{ .Ident "created_at" }},
|
||||
{{ .Ident "updated_at" }}
|
||||
)
|
||||
|
||||
VALUES (
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
1
|
||||
1,
|
||||
{{ .Arg .CreatedAt }},
|
||||
{{ .Arg .UpdatedAt }}
|
||||
)
|
||||
;
|
||||
|
113
pkg/services/store/entity/sqlstash/delete.go
Normal file
113
pkg/services/store/entity/sqlstash/delete.go
Normal file
@ -0,0 +1,113 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Delete")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
updatedBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete entity: %w", err)
|
||||
}
|
||||
|
||||
ret := new(entity.DeleteEntityResponse)
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
// Pre-locking: get the latest version of the entity
|
||||
previous, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
ret.Status = entity.DeleteEntityResponse_NOTFOUND
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-locking: remove this entity's labels
|
||||
delLabelsReq := sqlEntityLabelsDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: previous.Guid,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq); err != nil {
|
||||
return fmt.Errorf("delete all labels of entity with guid %q: %w",
|
||||
previous.Guid, err)
|
||||
}
|
||||
|
||||
// TODO: Pre-locking: remove this entity's refs from `entity_ref`
|
||||
|
||||
// Pre-locking: delete from "entity"
|
||||
delEntityReq := sqlEntityDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Key: key,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityDelete, delEntityReq); err != nil {
|
||||
return fmt.Errorf("delete entity with key %#v: %w", key, err)
|
||||
}
|
||||
|
||||
// Pre-locking: rebuild the whole folder tree structure if we're
|
||||
// deleting a folder
|
||||
if previous.Group == folder.GROUP && previous.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// k8s expects us to return the entity as it was before the deletion,
|
||||
// but with the updated RV
|
||||
previous.ResourceVersion = newVersion
|
||||
|
||||
// build the new row to be inserted
|
||||
deletedVersion := *previous // copy marshaled data since it won't change
|
||||
deletedVersion.Entity = cloneEntity(previous.Entity) // clone entity
|
||||
deletedVersion.Action = entity.Entity_DELETED
|
||||
deletedVersion.UpdatedAt = time.Now().UnixMilli()
|
||||
deletedVersion.UpdatedBy = updatedBy
|
||||
|
||||
// 2. Insert into entity history
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: &deletedVersion,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// success
|
||||
ret.Status = entity.DeleteEntityResponse_DELETED
|
||||
ret.Entity = previous.Entity
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we populate the Error field and how? (i.e. how to
|
||||
// determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("delete entity: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
@ -3,13 +3,16 @@ package sqlstash
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/session"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
type folderInfo struct {
|
||||
Guid string `json:"guid"`
|
||||
GUID string `json:"guid"`
|
||||
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"` // original display name
|
||||
@ -23,7 +26,7 @@ type folderInfo struct {
|
||||
right int32
|
||||
|
||||
// Build the tree
|
||||
parentUID string
|
||||
ParentUID string
|
||||
|
||||
// Calculated after query
|
||||
parent *folderInfo
|
||||
@ -31,56 +34,101 @@ type folderInfo struct {
|
||||
stack []*folderInfo
|
||||
}
|
||||
|
||||
// This will replace all entries in `entity_folder`
|
||||
// This is pretty heavy weight, but it does give us a sorted folder list
|
||||
// NOTE: this could be done async with a mutex/lock? reconciler pattern
|
||||
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, tx *session.SessionTx, namespace string) error {
|
||||
_, err := tx.Exec(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
func (fi *folderInfo) buildInsertItems(items *[]*sqlEntityFolderInsertRequestItem, namespace string, isLost bool) error {
|
||||
var js strings.Builder
|
||||
if err := json.NewEncoder(&js).Encode(fi.stack); err != nil {
|
||||
return fmt.Errorf("marshal stack of folder %q to JSON: %w", fi.SlugPath, err)
|
||||
}
|
||||
|
||||
query := "SELECT guid,name,folder,name,slug" +
|
||||
" FROM entity" +
|
||||
" WHERE " + s.dialect.Quote("group") + "=? AND resource=? AND namespace=?" +
|
||||
" ORDER BY slug asc"
|
||||
args := []interface{}{folder.GROUP, folder.RESOURCE, namespace}
|
||||
*items = append(*items, &sqlEntityFolderInsertRequestItem{
|
||||
GUID: fi.GUID,
|
||||
Namespace: namespace,
|
||||
UID: fi.UID,
|
||||
SlugPath: fi.SlugPath,
|
||||
JS: js.String(),
|
||||
Depth: fi.depth,
|
||||
Left: fi.left,
|
||||
Right: fi.right,
|
||||
Detached: isLost,
|
||||
})
|
||||
|
||||
all := []*folderInfo{}
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
for _, sub := range fi.children {
|
||||
if err := sub.buildInsertItems(items, namespace, isLost); err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
folder := folderInfo{
|
||||
children: []*folderInfo{},
|
||||
}
|
||||
err = rows.Scan(&folder.Guid, &folder.UID, &folder.parentUID, &folder.Name, &folder.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, &folder)
|
||||
}
|
||||
|
||||
root, lost, err := buildFolderTree(all)
|
||||
return nil
|
||||
}
|
||||
|
||||
// This rebuilds the whole folders structure for a given namespace. This has to
|
||||
// be done each time an entity is created or deleted.
|
||||
// FIXME: This is very inefficient and time consuming. This could be implemented
|
||||
// with a different approach instead of MPTT, or at least mitigated by an async
|
||||
// job?
|
||||
// FIXME: This algorithm apparently allows lost trees which are called
|
||||
// "detached"? We should probably migrate to something safer.
|
||||
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, x db.ContextExecer, namespace string) error {
|
||||
_, err := x.ExecContext(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("clear entity_folder for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
err = insertFolderInfo(ctx, tx, namespace, root, false)
|
||||
listReq := sqlEntityListFolderElementsRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Group: folder.GROUP,
|
||||
Resource: folder.RESOURCE,
|
||||
Namespace: namespace,
|
||||
FolderInfo: new(folderInfo),
|
||||
}
|
||||
query, err := sqltemplate.Execute(sqlEntityListFolderElements, listReq)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("execute SQL template to list folder items in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
for _, folder := range lost {
|
||||
err = insertFolderInfo(ctx, tx, namespace, folder, true)
|
||||
rows, err := x.QueryContext(ctx, query, listReq.GetArgs()...)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("list folder items in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
var itemList []*folderInfo
|
||||
for i := 1; rows.Next(); i++ {
|
||||
if err := rows.Scan(listReq.GetScanDest()...); err != nil {
|
||||
return fmt.Errorf("scan row #%d listing folder items in namespace %q: %w", i, namespace, err)
|
||||
}
|
||||
fi := *listReq.FolderInfo
|
||||
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)
|
||||
}
|
||||
|
||||
root, lost, err := buildFolderTree(itemList)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build folder tree for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
var insertItems []*sqlEntityFolderInsertRequestItem
|
||||
if err = root.buildInsertItems(&insertItems, namespace, false); err != nil {
|
||||
return fmt.Errorf("build insert items for root tree in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
for i, lostItem := range lost {
|
||||
if err = lostItem.buildInsertItems(&insertItems, namespace, false); err != nil {
|
||||
return fmt.Errorf("build insert items for lost folder #%d tree in namespace %q: %w", i, namespace, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
insReq := sqlEntityFolderInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Items: insertItems,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlEntityFolderInsert, insReq); err != nil {
|
||||
return fmt.Errorf("insert rebuilt tree for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
|
||||
@ -100,7 +148,7 @@ func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
|
||||
|
||||
// already sorted by slug
|
||||
for _, folder := range all {
|
||||
parent, ok := lookup[folder.parentUID]
|
||||
parent, ok := lookup[folder.ParentUID]
|
||||
if ok {
|
||||
folder.parent = parent
|
||||
parent.children = append(parent.children, folder)
|
||||
@ -136,32 +184,3 @@ func setMPTTOrder(folder *folderInfo, stack []*folderInfo, idx int32) (int32, er
|
||||
folder.right = idx + 1
|
||||
return folder.right, nil
|
||||
}
|
||||
|
||||
func insertFolderInfo(ctx context.Context, tx *session.SessionTx, namespace string, folder *folderInfo, isDetached bool) error {
|
||||
js, _ := json.Marshal(folder.stack)
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO entity_folder `+
|
||||
"(guid, namespace, name, slug_path, tree, depth, lft, rgt, detached) "+
|
||||
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.Guid,
|
||||
namespace,
|
||||
folder.UID,
|
||||
folder.SlugPath,
|
||||
string(js),
|
||||
folder.depth,
|
||||
folder.left,
|
||||
folder.right,
|
||||
isDetached,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sub := range folder.children {
|
||||
err := insertFolderInfo(ctx, tx, namespace, sub, isDetached)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -5,16 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFolderSupport(t *testing.T) {
|
||||
root, lost, err := buildFolderTree([]*folderInfo{
|
||||
{Guid: "GA", UID: "A", parentUID: "", Name: "A", Slug: "a"},
|
||||
{Guid: "GAA", UID: "AA", parentUID: "A", Name: "AA", Slug: "aa"},
|
||||
{Guid: "GB", UID: "B", parentUID: "", Name: "B", Slug: "b"},
|
||||
{GUID: "GA", UID: "A", ParentUID: "", Name: "A", Slug: "a"},
|
||||
{GUID: "GAA", UID: "AA", ParentUID: "A", Name: "AA", Slug: "aa"},
|
||||
{GUID: "GB", UID: "B", ParentUID: "", Name: "B", Slug: "b"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, root)
|
||||
|
@ -1,47 +1,111 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Templates.
|
||||
// Templates setup.
|
||||
var (
|
||||
//go:embed data
|
||||
templatesFs embed.FS
|
||||
//go:embed data/*.sql
|
||||
sqlTemplatesFS embed.FS
|
||||
|
||||
// all templates
|
||||
templates = template.Must(template.ParseFS(templatesFs, `data/*.sql`))
|
||||
|
||||
sqlEntityDelete = getTemplate("entity_delete.sql")
|
||||
sqlEntityInsert = getTemplate("entity_insert.sql")
|
||||
sqlEntityListFolderElements = getTemplate("entity_list_folder_elements.sql")
|
||||
sqlEntityUpdate = getTemplate("entity_update.sql")
|
||||
sqlEntityRead = getTemplate("entity_read.sql")
|
||||
|
||||
sqlEntityFolderInsert = getTemplate("entity_folder_insert.sql")
|
||||
|
||||
sqlEntityRefFind = getTemplate("entity_ref_find.sql")
|
||||
|
||||
sqlEntityLabelsDelete = getTemplate("entity_labels_delete.sql")
|
||||
sqlEntityLabelsInsert = getTemplate("entity_labels_insert.sql")
|
||||
|
||||
sqlKindVersionInc = getTemplate("kind_version_inc.sql")
|
||||
sqlKindVersionInsert = getTemplate("kind_version_insert.sql")
|
||||
sqlKindVersionLock = getTemplate("kind_version_lock.sql")
|
||||
helpers = template.FuncMap{
|
||||
"listSep": helperListSep,
|
||||
"join": helperJoin,
|
||||
}
|
||||
sqlTemplates = template.Must(template.New("sql").Funcs(helpers).ParseFS(sqlTemplatesFS, `data/*.sql`))
|
||||
)
|
||||
|
||||
func getTemplate(filename string) *template.Template {
|
||||
if t := templates.Lookup(filename); t != nil {
|
||||
func mustTemplate(filename string) *template.Template {
|
||||
if t := sqlTemplates.Lookup(filename); t != nil {
|
||||
return t
|
||||
}
|
||||
panic(fmt.Sprintf("template file not found: %s", filename))
|
||||
}
|
||||
|
||||
// Templates.
|
||||
var (
|
||||
sqlEntityDelete = mustTemplate("entity_delete.sql")
|
||||
sqlEntityHistory = mustTemplate("entity_history.sql")
|
||||
//sqlEntityHistoryList = mustTemplate("entity_history_list.sql") // TODO: in upcoming PRs
|
||||
sqlEntityInsert = mustTemplate("entity_insert.sql")
|
||||
sqlEntityListFolderElements = mustTemplate("entity_list_folder_elements.sql")
|
||||
sqlEntityUpdate = mustTemplate("entity_update.sql")
|
||||
sqlEntityRead = mustTemplate("entity_read.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")
|
||||
|
||||
sqlKindVersionGet = mustTemplate("kind_version_get.sql")
|
||||
sqlKindVersionInc = mustTemplate("kind_version_inc.sql")
|
||||
sqlKindVersionInsert = mustTemplate("kind_version_insert.sql")
|
||||
sqlKindVersionLock = mustTemplate("kind_version_lock.sql")
|
||||
)
|
||||
|
||||
// TxOptions.
|
||||
var (
|
||||
ReadCommitted = &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
}
|
||||
ReadCommittedRO = &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
ReadOnly: true,
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (e SQLError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// entity_folder table requests.
|
||||
|
||||
type sqlEntityFolderInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Items []*sqlEntityFolderInsertRequestItem
|
||||
@ -59,12 +123,16 @@ type sqlEntityFolderInsertRequestItem struct {
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// entity_ref table requests.
|
||||
|
||||
type sqlEntityRefFindRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Request *entity.ReferenceRequest
|
||||
Entity *withSerialized
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
// entity_labels table requests.
|
||||
|
||||
type sqlEntityLabelsInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
GUID string
|
||||
@ -77,29 +145,50 @@ type sqlEntityLabelsDeleteRequest struct {
|
||||
KeepLabels []string
|
||||
}
|
||||
|
||||
// entity_kind table requests.
|
||||
|
||||
type returnsKindVersion struct {
|
||||
ResourceVersion int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func (r *returnsKindVersion) Results() (*returnsKindVersion, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type sqlKindVersionGetRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
Resource string
|
||||
*returnsKindVersion
|
||||
}
|
||||
|
||||
type sqlKindVersionLockRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
ResourceVersion int64
|
||||
*returnsKindVersion
|
||||
}
|
||||
|
||||
type sqlKindVersionIncRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
ResourceVersion int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type sqlKindVersionInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// entity and entity_history tables requests.
|
||||
|
||||
type sqlEntityListFolderElementsRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
@ -108,12 +197,16 @@ type sqlEntityListFolderElementsRequest struct {
|
||||
FolderInfo *folderInfo
|
||||
}
|
||||
|
||||
// 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
|
||||
// cases and proper database deserialization.
|
||||
type sqlEntityReadRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Key *entity.Key
|
||||
ResourceVersion int64
|
||||
SelectForUpdate bool
|
||||
Entity *withSerialized
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
type sqlEntityDeleteRequest struct {
|
||||
@ -121,9 +214,21 @@ type sqlEntityDeleteRequest struct {
|
||||
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
|
||||
}
|
||||
|
||||
type sqlEntityInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Entity *withSerialized
|
||||
Entity *returnsEntity
|
||||
|
||||
// TableEntity, when true, means we will insert into table "entity", and
|
||||
// into table "entity_history" otherwise.
|
||||
@ -132,33 +237,269 @@ type sqlEntityInsertRequest struct {
|
||||
|
||||
type sqlEntityUpdateRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Entity *withSerialized
|
||||
Entity *returnsEntity
|
||||
}
|
||||
|
||||
// withSerialized provides access to the wire Entiity DTO as well as the
|
||||
// serialized version of some of its fields suitable to be read from or written
|
||||
// to the database.
|
||||
type withSerialized struct {
|
||||
*entity.Entity
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneEntity(src *entity.Entity) *entity.Entity {
|
||||
ret := newEmptyEntity()
|
||||
proto.Merge(ret, src)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// 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.
|
||||
type returnsEntitySet struct {
|
||||
Entity *returnsEntity
|
||||
}
|
||||
|
||||
// newWithResults returns a new newWithResults.
|
||||
func newReturnsEntitySet() returnsEntitySet {
|
||||
return returnsEntitySet{
|
||||
Entity: newReturnsEntity(),
|
||||
}
|
||||
}
|
||||
|
||||
// Results is part of the implementation of sqltemplate.WithResults that
|
||||
// deserializes the database data into an internal *entity.Entity, and then
|
||||
// returns a deep copy of it.
|
||||
func (e returnsEntitySet) Results() (*entity.Entity, error) {
|
||||
ent, err := e.Entity.Results()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proto.Clone(ent).(*entity.Entity), 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.
|
||||
type returnsEntity struct {
|
||||
*entity.Entity
|
||||
Labels []byte
|
||||
Fields []byte
|
||||
Errors []byte
|
||||
}
|
||||
|
||||
// TODO: remove once we start using these symbols. Prevents `unused` linter
|
||||
// until the next PR.
|
||||
var (
|
||||
_, _, _ = sqlEntityDelete, sqlEntityInsert, sqlEntityListFolderElements
|
||||
_, _, _ = sqlEntityUpdate, sqlEntityRead, sqlEntityFolderInsert
|
||||
_, _, _ = sqlEntityRefFind, sqlEntityLabelsDelete, sqlEntityLabelsInsert
|
||||
_, _, _ = sqlKindVersionInc, sqlKindVersionInsert, sqlKindVersionLock
|
||||
_, _ = sqlEntityFolderInsertRequest{}, sqlEntityFolderInsertRequestItem{}
|
||||
_, _ = sqlEntityRefFindRequest{}, sqlEntityLabelsInsertRequest{}
|
||||
_, _ = sqlEntityLabelsInsertRequest{}, sqlEntityLabelsDeleteRequest{}
|
||||
_, _ = sqlKindVersionLockRequest{}, sqlKindVersionIncRequest{}
|
||||
_, _ = sqlKindVersionInsertRequest{}, sqlEntityListFolderElementsRequest{}
|
||||
_, _ = sqlEntityReadRequest{}, sqlEntityDeleteRequest{}
|
||||
_, _ = sqlEntityInsertRequest{}, sqlEntityUpdateRequest{}
|
||||
_ = withSerialized{}
|
||||
)
|
||||
func newReturnsEntity() *returnsEntity {
|
||||
return &returnsEntity{
|
||||
Entity: newEmptyEntity(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *returnsEntity) Results() (*entity.Entity, error) {
|
||||
if err := e.unmarshal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return e.Entity, nil
|
||||
}
|
||||
|
||||
// marshal serializes the fields from the wire protocol representation so they
|
||||
// can be written to the database.
|
||||
func (e *returnsEntity) marshal() error {
|
||||
var err error
|
||||
|
||||
if len(e.Entity.Labels) == 0 {
|
||||
e.Labels = []byte{'{', '}'}
|
||||
} else {
|
||||
e.Labels, err = json.Marshal(e.Entity.Labels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"labels\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Entity.Fields) == 0 {
|
||||
e.Fields = []byte{'{', '}'}
|
||||
} else {
|
||||
e.Fields, err = json.Marshal(e.Entity.Fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"fields\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Entity.Errors) == 0 {
|
||||
e.Errors = []byte{'[', ']'}
|
||||
} else {
|
||||
e.Errors, err = json.Marshal(e.Entity.Errors)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"errors\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unmarshal deserializes the fields in the database representation so they can
|
||||
// be written to the wire protocol.
|
||||
func (e *returnsEntity) unmarshal() error {
|
||||
if len(e.Labels) > 0 {
|
||||
if err := json.Unmarshal(e.Labels, &e.Entity.Labels); err != nil {
|
||||
return fmt.Errorf("deserialize entity \"labels\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readEntity(
|
||||
ctx context.Context,
|
||||
x db.ContextExecer,
|
||||
d sqltemplate.Dialect,
|
||||
k *entity.Key,
|
||||
asOfVersion int64,
|
||||
optimisticLocking bool,
|
||||
selectForUpdate bool,
|
||||
) (*returnsEntity, error) {
|
||||
if asOfVersion < 0 {
|
||||
asOfVersion = 0
|
||||
}
|
||||
if asOfVersion == 0 {
|
||||
optimisticLocking = false
|
||||
}
|
||||
|
||||
v := asOfVersion
|
||||
if optimisticLocking {
|
||||
// for optimistic locking, we will not ask for a specific version, but
|
||||
// instead retrieve the latest version from the table "entity" and
|
||||
// manually compare if it matches the given value of "asOfVersion".
|
||||
v = 0
|
||||
}
|
||||
|
||||
readReq := sqlEntityReadRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Key: k,
|
||||
ResourceVersion: v,
|
||||
SelectForUpdate: selectForUpdate,
|
||||
returnsEntitySet: newReturnsEntitySet(),
|
||||
}
|
||||
ent, err := queryRow(ctx, x, sqlEntityRead, readReq)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read entity: %w", err)
|
||||
}
|
||||
|
||||
if ent.Action == entity.Entity_DELETED {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if optimisticLocking && asOfVersion != 0 && ent.ResourceVersion != asOfVersion {
|
||||
return nil, ErrOptimisticLockingFailed
|
||||
}
|
||||
|
||||
return readReq.Entity, nil
|
||||
}
|
||||
|
||||
// kindVersionAtomicInc atomically increases the version of a kind within a
|
||||
// transaction.
|
||||
func kindVersionAtomicInc(ctx context.Context, x db.ContextExecer, d sqltemplate.Dialect, group, resource string) (newVersion int64, err error) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 1. Lock the kind and get the latest version
|
||||
lockReq := sqlKindVersionLockRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
returnsKindVersion: new(returnsKindVersion),
|
||||
}
|
||||
kindv, err := queryRow(ctx, x, sqlKindVersionLock, lockReq)
|
||||
|
||||
// if there wasn't a row associated with the given kind, we create one with
|
||||
// version 1
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// NOTE: there is a marginal chance that we race with another writer
|
||||
// trying to create the same row. This is only possible when onboarding
|
||||
// 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
|
||||
// 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
|
||||
// complexity to the code. That would be preferrable to changing
|
||||
// Dialect, though. The current alternative, just retrying, seems to be
|
||||
// enough for now.
|
||||
insReq := sqlKindVersionInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlKindVersionInsert, insReq); err != nil {
|
||||
return 0, fmt.Errorf("insert into kind_version: %w", err)
|
||||
}
|
||||
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock kind: %w", err)
|
||||
}
|
||||
|
||||
incReq := sqlKindVersionIncRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
ResourceVersion: kindv.ResourceVersion,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlKindVersionInc, incReq); err != nil {
|
||||
return 0, fmt.Errorf("increase kind version: %w", err)
|
||||
}
|
||||
|
||||
return kindv.ResourceVersion + 1, nil
|
||||
}
|
||||
|
||||
// Template helpers.
|
||||
|
||||
// helperListSep is a helper that helps writing simpler loops in SQL templates.
|
||||
// Example usage:
|
||||
//
|
||||
// {{ $comma := listSep ", " }}
|
||||
// {{ range .Values }}
|
||||
// {{/* here we put "-" on each end to remove extra white space */}}
|
||||
// {{- call $comma -}}
|
||||
// {{ .Value }}
|
||||
// {{ end }}
|
||||
func helperListSep(sep string) func() string {
|
||||
var addSep bool
|
||||
|
||||
return func() string {
|
||||
if addSep {
|
||||
return sep
|
||||
}
|
||||
addSep = true
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func helperJoin(sep string, elems ...string) string {
|
||||
return strings.Join(elems, sep)
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
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"
|
||||
@ -25,113 +25,11 @@ func TestIsHealthy(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
s := setUpTestServer(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ent *entity.Entity
|
||||
errIsExpected bool
|
||||
statusIsExpected bool
|
||||
}{
|
||||
{
|
||||
"request with key and entity creator",
|
||||
&entity.Entity{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: "default",
|
||||
Name: "set-minimum-uid",
|
||||
Key: "/playlist.grafana.app/playlists/namespaces/default/set-minimum-uid",
|
||||
CreatedBy: "set-minimum-creator",
|
||||
Origin: &entity.EntityOriginInfo{},
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"request with no entity creator",
|
||||
&entity.Entity{
|
||||
Key: "/playlist.grafana.app/playlists/namespaces/default/set-only-key",
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"request with no key",
|
||||
&entity.Entity{
|
||||
CreatedBy: "entity-creator",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := entity.CreateEntityRequest{
|
||||
Entity: &entity.Entity{
|
||||
Key: tc.ent.Key,
|
||||
CreatedBy: tc.ent.CreatedBy,
|
||||
},
|
||||
}
|
||||
resp, err := s.Create(context.Background(), &req)
|
||||
|
||||
if tc.errIsExpected {
|
||||
require.Error(t, err)
|
||||
|
||||
if tc.statusIsExpected {
|
||||
require.Equal(t, entity.CreateEntityResponse_ERROR, resp.Status)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, entity.CreateEntityResponse_CREATED, resp.Status)
|
||||
require.NotNil(t, resp)
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
read, err := s.Read(context.Background(), &entity.ReadEntityRequest{
|
||||
Key: tc.ent.Key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, read)
|
||||
|
||||
require.Greater(t, len(read.Guid), 0)
|
||||
require.Greater(t, read.ResourceVersion, int64(0))
|
||||
|
||||
expectedETag := createContentsHash(tc.ent.Body, tc.ent.Meta, tc.ent.Status)
|
||||
require.Equal(t, expectedETag, read.ETag)
|
||||
require.Equal(t, tc.ent.Origin, read.Origin)
|
||||
require.Equal(t, tc.ent.Group, read.Group)
|
||||
require.Equal(t, tc.ent.Resource, read.Resource)
|
||||
require.Equal(t, tc.ent.Namespace, read.Namespace)
|
||||
require.Equal(t, tc.ent.Name, read.Name)
|
||||
require.Equal(t, tc.ent.Subresource, read.Subresource)
|
||||
require.Equal(t, tc.ent.GroupVersion, read.GroupVersion)
|
||||
require.Equal(t, tc.ent.Key, read.Key)
|
||||
require.Equal(t, tc.ent.Folder, read.Folder)
|
||||
require.Equal(t, tc.ent.Meta, read.Meta)
|
||||
require.Equal(t, tc.ent.Body, read.Body)
|
||||
require.Equal(t, tc.ent.Status, read.Status)
|
||||
require.Equal(t, tc.ent.Title, read.Title)
|
||||
require.Equal(t, tc.ent.Size, read.Size)
|
||||
require.Greater(t, read.CreatedAt, int64(0))
|
||||
require.Equal(t, tc.ent.CreatedBy, read.CreatedBy)
|
||||
require.Equal(t, tc.ent.UpdatedAt, read.UpdatedAt)
|
||||
require.Equal(t, tc.ent.UpdatedBy, read.UpdatedBy)
|
||||
require.Equal(t, tc.ent.Description, read.Description)
|
||||
require.Equal(t, tc.ent.Slug, read.Slug)
|
||||
require.Equal(t, tc.ent.Message, read.Message)
|
||||
require.Equal(t, tc.ent.Labels, read.Labels)
|
||||
require.Equal(t, tc.ent.Fields, read.Fields)
|
||||
require.Equal(t, tc.ent.Errors, read.Errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setUpTestServer(t *testing.T) entity.EntityStoreServer {
|
||||
sqlStore, cfg := db.InitTestDBWithCfg(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
sqlStore, cfg := oldDB.InitTestDBWithCfg(t)
|
||||
|
||||
entityDB, err := dbimpl.ProvideEntityDB(
|
||||
sqlStore,
|
||||
@ -150,3 +48,18 @@ func setUpTestServer(t *testing.T) entity.EntityStoreServer {
|
||||
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{}
|
||||
)
|
||||
|
@ -1,18 +1,80 @@
|
||||
package sqltemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Args errors.
|
||||
var (
|
||||
ErrInvalidArgList = errors.New("invalid arglist")
|
||||
)
|
||||
|
||||
// Args keeps the data that needs to be passed to the engine for execution in
|
||||
// the right order. Add it to your data types passed to SQLTemplate, either by
|
||||
// embedding or with a named struct field if its Arg method would clash with
|
||||
// another struct field.
|
||||
type Args []any
|
||||
type Args struct {
|
||||
d Dialect
|
||||
values []any
|
||||
}
|
||||
|
||||
func NewArgs(d Dialect) *Args {
|
||||
return &Args{
|
||||
d: d,
|
||||
}
|
||||
}
|
||||
|
||||
// Arg can be called from within templates to pass arguments to the SQL driver
|
||||
// to use in the execution of the query.
|
||||
func (a *Args) Arg(x any) string {
|
||||
*a = append(*a, x)
|
||||
return "?"
|
||||
a.values = append(a.values, x)
|
||||
|
||||
return a.d.ArgPlaceholder(len(a.values))
|
||||
}
|
||||
|
||||
func (a *Args) GetArgs() Args {
|
||||
return *a
|
||||
// ArgList returns a comma separated list of `?` placeholders for each element
|
||||
// in the provided slice argument, calling Arg for each of them.
|
||||
// Example struct:
|
||||
//
|
||||
// type sqlMyRequest struct {
|
||||
// *sqltemplate.SQLTemplate
|
||||
// IDs []int64
|
||||
// }
|
||||
//
|
||||
// Example usage in a SQL template:
|
||||
//
|
||||
// DELETE FROM {{ .Ident "mytab" }}
|
||||
// WHERE id IN ( {{ argList . .IDs }} )
|
||||
// ;
|
||||
func (a *Args) ArgList(slice reflect.Value) (string, error) {
|
||||
if !slice.IsValid() || slice.Kind() != reflect.Slice {
|
||||
return "", ErrInvalidArgList
|
||||
}
|
||||
sliceLen := slice.Len()
|
||||
if sliceLen == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(3*sliceLen - 2) // the list will be ?, ?, ?
|
||||
for i, l := 0, slice.Len(); i < l; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(a.Arg(slice.Index(i).Interface()))
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (a *Args) GetArgs() []any {
|
||||
return a.values
|
||||
}
|
||||
|
||||
type ArgsIface interface {
|
||||
Arg(x any) string
|
||||
ArgList(slice reflect.Value) (string, error)
|
||||
GetArgs() []any
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ func TestArgs_Arg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
a := new(Args)
|
||||
a := NewArgs(MySQL)
|
||||
|
||||
shouldBeQuestionMark(t, a.Arg(0))
|
||||
shouldBeQuestionMark(t, a.Arg(1))
|
||||
|
@ -2,6 +2,7 @@ package sqltemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -21,6 +22,12 @@ type Dialect interface {
|
||||
// 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.
|
||||
ArgPlaceholder(argNum int) string
|
||||
|
||||
// SelectFor parses and returns the given row-locking clause for a SELECT
|
||||
// statement. If the clause is invalid it returns an error. Implementations
|
||||
// of this method should use ParseRowLockingClause.
|
||||
@ -97,3 +104,18 @@ func (standardIdent) Ident(s string) (string, error) {
|
||||
}
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`, nil
|
||||
}
|
||||
|
||||
type argPlaceholderFunc func(int) string
|
||||
|
||||
func (f argPlaceholderFunc) ArgPlaceholder(argNum int) string {
|
||||
return f(argNum)
|
||||
}
|
||||
|
||||
var (
|
||||
argFmtSQL92 = argPlaceholderFunc(func(int) string {
|
||||
return "?"
|
||||
})
|
||||
argFmtPositional = argPlaceholderFunc(func(argNum int) string {
|
||||
return "$" + strconv.Itoa(argNum)
|
||||
})
|
||||
)
|
||||
|
@ -7,6 +7,7 @@ package sqltemplate
|
||||
// https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes
|
||||
var MySQL = mysql{
|
||||
rowLockingClauseAll: true,
|
||||
argPlaceholderFunc: argFmtSQL92,
|
||||
}
|
||||
|
||||
var _ Dialect = MySQL
|
||||
@ -14,4 +15,5 @@ var _ Dialect = MySQL
|
||||
type mysql struct {
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
// PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS.
|
||||
var PostgreSQL = postgresql{
|
||||
rowLockingClauseAll: true,
|
||||
argPlaceholderFunc: argFmtPositional,
|
||||
}
|
||||
|
||||
var _ Dialect = PostgreSQL
|
||||
@ -20,6 +21,7 @@ var (
|
||||
type postgresql struct {
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
||||
func (p postgresql) Ident(s string) (string, error) {
|
||||
|
@ -3,6 +3,7 @@ package sqltemplate
|
||||
// SQLite is an implementation of Dialect for the SQLite DMBS.
|
||||
var SQLite = sqlite{
|
||||
rowLockingClauseAll: false,
|
||||
argPlaceholderFunc: argFmtSQL92,
|
||||
}
|
||||
|
||||
var _ Dialect = SQLite
|
||||
@ -12,4 +13,5 @@ type sqlite struct {
|
||||
// https://www.sqlite.org/lang_keywords.html
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
@ -103,15 +103,15 @@ func Example() {
|
||||
|
||||
// Assuming that we have a *sql.DB object named "db", we could now make our
|
||||
// query with:
|
||||
// row := db.QueryRowContext(ctx, query, queryData.Args...)
|
||||
// row := db.QueryRowContext(ctx, query, queryData.GetArgs()...)
|
||||
// // and check row.Err() here
|
||||
|
||||
// As we're not actually running a database in this example, let's verify
|
||||
// that we find our arguments populated as expected instead:
|
||||
if len(queryData.Args) != 1 {
|
||||
if len(queryData.GetArgs()) != 1 {
|
||||
panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args))
|
||||
}
|
||||
id, ok := queryData.Args[0].(int)
|
||||
id, ok := queryData.GetArgs()[0].(int)
|
||||
if !ok || id != queryData.Request.ID {
|
||||
panic(fmt.Sprintf("unexpected args: %#v", queryData.Args))
|
||||
}
|
||||
@ -119,25 +119,25 @@ func Example() {
|
||||
// In your code you would now have "row" populated with the row data,
|
||||
// assuming that the operation succeeded, so you would now scan the row data
|
||||
// abd populate the values of our response:
|
||||
// err := row.Scan(queryData.ScanDest...)
|
||||
// err := row.Scan(queryData.GetScanDest()...)
|
||||
// // and check err here
|
||||
|
||||
// Again, as we're not actually running a database in this example, we will
|
||||
// instead run the code to assert that queryData.ScanDest was populated with
|
||||
// the expected data, which should be pointers to each of the fields of
|
||||
// Response so that the Scan method can write to them:
|
||||
if len(queryData.ScanDest) != 3 {
|
||||
if len(queryData.GetScanDest()) != 3 {
|
||||
panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest))
|
||||
}
|
||||
idPtr, ok := queryData.ScanDest[0].(*int)
|
||||
idPtr, ok := queryData.GetScanDest()[0].(*int)
|
||||
if !ok || idPtr != &queryData.Response.ID {
|
||||
panic(fmt.Sprintf("unexpected response 'id' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
typePtr, ok := queryData.ScanDest[1].(*string)
|
||||
typePtr, ok := queryData.GetScanDest()[1].(*string)
|
||||
if !ok || typePtr != &queryData.Response.Type {
|
||||
panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
namePtr, ok := queryData.ScanDest[2].(*string)
|
||||
namePtr, ok := queryData.GetScanDest()[2].(*string)
|
||||
if !ok || namePtr != &queryData.Response.Name {
|
||||
panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
|
@ -5,18 +5,25 @@ import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type ScanDest []any
|
||||
type ScanDest struct {
|
||||
values []any
|
||||
}
|
||||
|
||||
func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) {
|
||||
if !v.IsValid() || !v.CanAddr() || !v.Addr().CanInterface() {
|
||||
return "", fmt.Errorf("invalid or unaddressable value: %v", colName)
|
||||
}
|
||||
|
||||
*i = append(*i, v.Addr().Interface())
|
||||
i.values = append(i.values, v.Addr().Interface())
|
||||
|
||||
return colName, nil
|
||||
}
|
||||
|
||||
func (i *ScanDest) GetScanDest() ScanDest {
|
||||
return *i
|
||||
func (i *ScanDest) GetScanDest() []any {
|
||||
return i.values
|
||||
}
|
||||
|
||||
type ScanDestIface interface {
|
||||
Into(v reflect.Value, colName string) (string, error)
|
||||
GetScanDest() []any
|
||||
}
|
||||
|
@ -23,13 +23,15 @@ func TestScanDest_Into(t *testing.T) {
|
||||
dataVal := reflect.ValueOf(&data).Elem()
|
||||
|
||||
colName, err = d.Into(dataVal.FieldByName("X"), "some int")
|
||||
if err != nil || colName != "some int" || len(d) != 1 || d[0] != &data.X {
|
||||
v := d.GetScanDest()
|
||||
if err != nil || colName != "some int" || 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")
|
||||
if err != nil || colName != "some byte" || len(d) != 2 || d[1] != &data.Y {
|
||||
v = d.GetScanDest()
|
||||
if err != nil || colName != "some byte" || len(v) != 2 || v[1] != &data.Y {
|
||||
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
|
||||
colName, err, d)
|
||||
}
|
||||
|
@ -1,26 +1,47 @@
|
||||
package sqltemplate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// SQLTemplate provides comprehensive support for SQL templating, handling
|
||||
// dialect traits, execution arguments and scanning arguments.
|
||||
type SQLTemplate struct {
|
||||
Dialect
|
||||
Args
|
||||
ScanDest
|
||||
}
|
||||
|
||||
// New returns a nee *SQLTemplate that will use the given dialect.
|
||||
func New(d Dialect) *SQLTemplate {
|
||||
return &SQLTemplate{
|
||||
Args: Args{
|
||||
d: d,
|
||||
},
|
||||
Dialect: d,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLTemplateIface can be used as argument in general purpose utilities
|
||||
// expecting a struct embedding *SQLTemplate.
|
||||
type SQLTemplateIface interface {
|
||||
Dialect
|
||||
GetArgs() Args
|
||||
GetScanDest() ScanDest
|
||||
ArgsIface
|
||||
ScanDestIface
|
||||
}
|
||||
|
||||
// WithResults has an additional method suited for structs embedding
|
||||
// *SQLTemplate and returning a set of rows.
|
||||
type WithResults[T any] interface {
|
||||
SQLTemplateIface
|
||||
|
||||
// Results returns the results of the query. If the query is expected to
|
||||
// return a set of rows, then it should be a deep copy of the internal
|
||||
// results, so that it can be called multiple times to get the different
|
||||
// values.
|
||||
Results() (T, error)
|
||||
}
|
||||
|
||||
// Execute is a trivial utility to execute and return the results of any
|
||||
@ -33,3 +54,38 @@ 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:
|
||||
// 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
|
||||
// spaces. Code looking like this is already a smell. Avoid string literals,
|
||||
// pass them as arguments so they can be appropriately escaped by the
|
||||
// corresponding driver. And identifiers with white space should be avoided
|
||||
// in all cases as well.
|
||||
func FormatSQL(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
for _, f := range formatREs {
|
||||
q = f.re.ReplaceAllString(q, f.replacement)
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
type reFormatting struct {
|
||||
re *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
var formatREs = []reFormatting{
|
||||
{re: regexp.MustCompile(`\s+`), replacement: " "},
|
||||
{re: regexp.MustCompile(` ?([+-/*=<>%!~]+) ?`), replacement: " $1 "},
|
||||
{re: regexp.MustCompile(`([([{]) `), replacement: "$1"},
|
||||
{re: regexp.MustCompile(` ([)\]}])`), replacement: "$1"},
|
||||
{re: regexp.MustCompile(` ?, ?`), replacement: ", "},
|
||||
{re: regexp.MustCompile(` ?([;.:]) ?`), replacement: "$1"},
|
||||
}
|
||||
|
@ -3,16 +3,16 @@
|
||||
// Frame[0]
|
||||
// Name:
|
||||
// Dimensions: 7 Fields by 4 Rows
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
// | Name: UID | Name: name | Name: slug | Name: depth | Name: left | Name: right | Name: tree |
|
||||
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
|
||||
// | Type: []string | Type: []string | Type: []string | Type: []int32 | Type: []int32 | Type: []int32 | Type: []json.RawMessage |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
// | | Root | | 0 | 1 | 8 | [] |
|
||||
// | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"}] |
|
||||
// | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/"}] |
|
||||
// | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/"}] |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""}] |
|
||||
// | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/","ParentUID":"A"}] |
|
||||
// | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/","ParentUID":""}] |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
@ -118,7 +118,8 @@
|
||||
"guid": "GA",
|
||||
"uid": "A",
|
||||
"name": "A",
|
||||
"slug": "/a/"
|
||||
"slug": "/a/",
|
||||
"ParentUID": ""
|
||||
}
|
||||
],
|
||||
[
|
||||
@ -126,13 +127,15 @@
|
||||
"guid": "GA",
|
||||
"uid": "A",
|
||||
"name": "A",
|
||||
"slug": "/a/"
|
||||
"slug": "/a/",
|
||||
"ParentUID": ""
|
||||
},
|
||||
{
|
||||
"guid": "GAA",
|
||||
"uid": "AA",
|
||||
"name": "AA",
|
||||
"slug": "/a/aa/"
|
||||
"slug": "/a/aa/",
|
||||
"ParentUID": "A"
|
||||
}
|
||||
],
|
||||
[
|
||||
@ -140,7 +143,8 @@
|
||||
"guid": "GB",
|
||||
"uid": "B",
|
||||
"name": "B",
|
||||
"slug": "/b/"
|
||||
"slug": "/b/",
|
||||
"ParentUID": ""
|
||||
}
|
||||
]
|
||||
]
|
||||
|
198
pkg/services/store/entity/sqlstash/update.go
Normal file
198
pkg/services/store/entity/sqlstash/update.go
Normal file
@ -0,0 +1,198 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequest) (*entity.UpdateEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Update")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Entity.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
updatedBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update entity: get user from context: %w", err)
|
||||
}
|
||||
|
||||
ret := new(entity.UpdateEntityResponse)
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
// Pre-locking: get the latest version of the entity
|
||||
oldEntity, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// build the entity from the request and the old data
|
||||
newEntity, err := entityForUpdate(updatedBy, oldEntity.Entity, r.Entity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("")
|
||||
}
|
||||
keepLabels, insertLabels := diffLabels(oldEntity.Entity.Labels, r.Entity.Labels)
|
||||
|
||||
// Pre-locking: delete old labels
|
||||
if len(keepLabels) > 0 {
|
||||
delLabelsReq := sqlEntityLabelsDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: oldEntity.Guid,
|
||||
KeepLabels: keepLabels,
|
||||
}
|
||||
_, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete old labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-locking: insert new labels
|
||||
if len(insertLabels) > 0 {
|
||||
insLabelsReq := sqlEntityLabelsInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: oldEntity.Guid,
|
||||
Labels: insertLabels,
|
||||
}
|
||||
_, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabelsReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert new labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newEntity.ResourceVersion = newVersion
|
||||
|
||||
// 2. Update entity
|
||||
updEntityReq := sqlEntityUpdateRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityUpdate, updEntityReq); err != nil {
|
||||
return fmt.Errorf("update entity: %w", err)
|
||||
}
|
||||
|
||||
// 3. Insert into entity history
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// 4. Rebuild the whole folder tree structure if we're updating a folder
|
||||
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// success
|
||||
ret.Entity = newEntity.Entity
|
||||
ret.Status = entity.UpdateEntityResponse_UPDATED
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we define the "Error" field here and how? (i.e. how
|
||||
// to determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("update entity: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func diffLabels(oldLabels, newLabels map[string]string) (keepLabels []string, insertLabels map[string]string) {
|
||||
insertLabels = maps.Clone(newLabels)
|
||||
for oldk, oldv := range oldLabels {
|
||||
newv, ok := insertLabels[oldk]
|
||||
if ok && oldv == newv {
|
||||
keepLabels = append(keepLabels, oldk)
|
||||
delete(insertLabels, oldk)
|
||||
}
|
||||
}
|
||||
|
||||
return keepLabels, insertLabels
|
||||
}
|
||||
|
||||
// entityForUpdate populates a *returnsEntity taking the relevant parts from
|
||||
// the requested update and keeping the necessary values from the old one.
|
||||
func entityForUpdate(updatedBy string, oldEntity, newEntity *entity.Entity) (*returnsEntity, error) {
|
||||
newOrigin := ptrOr(newEntity.Origin)
|
||||
oldOrigin := ptrOr(oldEntity.Origin)
|
||||
|
||||
ret := &returnsEntity{
|
||||
Entity: &entity.Entity{
|
||||
Guid: oldEntity.Guid, // read-only
|
||||
// ResourceVersion is later set after reading `kind_version` table
|
||||
|
||||
Key: oldEntity.Key, // read-only
|
||||
|
||||
Group: oldEntity.Group, // read-only
|
||||
GroupVersion: cmp.Or(newEntity.GroupVersion, oldEntity.GroupVersion),
|
||||
Resource: oldEntity.Resource, // read-only
|
||||
Namespace: oldEntity.Namespace, // read-only
|
||||
Name: oldEntity.Name, // read-only
|
||||
|
||||
Folder: cmp.Or(newEntity.Folder, oldEntity.Folder),
|
||||
|
||||
Meta: sliceOr(newEntity.Meta, oldEntity.Meta),
|
||||
Body: sliceOr(newEntity.Body, oldEntity.Body),
|
||||
Status: sliceOr(newEntity.Status, oldEntity.Status),
|
||||
|
||||
Size: int64(cmp.Or(len(newEntity.Body), len(oldEntity.Body))),
|
||||
ETag: cmp.Or(newEntity.ETag, oldEntity.ETag),
|
||||
|
||||
CreatedAt: oldEntity.CreatedAt, // read-only
|
||||
CreatedBy: oldEntity.CreatedBy, // read-only
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
UpdatedBy: updatedBy,
|
||||
|
||||
Origin: &entity.EntityOriginInfo{
|
||||
Source: cmp.Or(newOrigin.Source, oldOrigin.Source),
|
||||
Key: cmp.Or(newOrigin.Key, oldOrigin.Key),
|
||||
Time: cmp.Or(newOrigin.Time, oldOrigin.Time),
|
||||
},
|
||||
|
||||
Title: cmp.Or(newEntity.Title, oldEntity.Title),
|
||||
Slug: cmp.Or(newEntity.Slug, oldEntity.Slug),
|
||||
Description: cmp.Or(newEntity.Description, oldEntity.Description),
|
||||
|
||||
Message: cmp.Or(newEntity.Message, oldEntity.Message),
|
||||
Labels: mapOr(newEntity.Labels, oldEntity.Labels),
|
||||
Fields: mapOr(newEntity.Fields, oldEntity.Fields),
|
||||
Errors: newEntity.Errors,
|
||||
|
||||
Action: entity.Entity_UPDATED,
|
||||
},
|
||||
}
|
||||
|
||||
if len(newEntity.Body) != 0 ||
|
||||
len(newEntity.Meta) != 0 ||
|
||||
len(newEntity.Status) != 0 {
|
||||
ret.ETag = createETag(ret.Body, ret.Meta, ret.Status)
|
||||
}
|
||||
|
||||
if err := ret.marshal(); err != nil {
|
||||
return nil, fmt.Errorf("serialize entity data for db: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
@ -1,11 +1,20 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/store"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
func createContentsHash(body []byte, meta []byte, status []byte) string {
|
||||
func createETag(body []byte, meta []byte, status []byte) string {
|
||||
h := md5.New()
|
||||
_, _ = h.Write(meta)
|
||||
_, _ = h.Write(body)
|
||||
@ -13,3 +22,168 @@ func createContentsHash(body []byte, meta []byte, status []byte) string {
|
||||
hash := h.Sum(nil)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// getCurrentUser returns a string identifying the user making a request with
|
||||
// the given context.
|
||||
func getCurrentUser(ctx context.Context) (string, error) {
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil || user == nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrUserNotFoundInContext, err)
|
||||
}
|
||||
|
||||
return store.GetUserIDString(user), nil
|
||||
}
|
||||
|
||||
// ptrOr returns the first non-nil pointer in the least or a new non-nil
|
||||
// pointer.
|
||||
func ptrOr[P ~*E, E any](ps ...P) P {
|
||||
for _, p := range ps {
|
||||
if p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return P(new(E))
|
||||
}
|
||||
|
||||
// sliceOr returns the first slice that has at least one element, or a non-nil
|
||||
// empty slice.
|
||||
func sliceOr[S ~[]E, E comparable](vals ...S) S {
|
||||
for _, s := range vals {
|
||||
if len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return S{}
|
||||
}
|
||||
|
||||
// mapOr returns the first map that has at least one element, or a non-nil empty
|
||||
// map.
|
||||
func mapOr[M ~map[K]V, K comparable, V any](vals ...M) M {
|
||||
for _, m := range vals {
|
||||
if len(m) > 0 {
|
||||
return 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
|
||||
|
||||
rawQuery, err := sqltemplate.Execute(tmpl, req)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
query := sqltemplate.FormatSQL(rawQuery)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return scanRow(row, req)
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanRow is used on *sql.Row and *sql.Rows, and is factored out here not to
|
||||
// improving code reuse, but rather for ease of testing.
|
||||
func scanRow[T any](sc scanner, req sqltemplate.WithResults[T]) (zero T, err error) {
|
||||
if err = sc.Scan(req.GetScanDest()...); err != nil {
|
||||
return zero, fmt.Errorf("row scan: %w", err)
|
||||
}
|
||||
|
||||
res, err := req.Results()
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("row results: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
rawQuery, err := sqltemplate.Execute(tmpl, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
query := sqltemplate.FormatSQL(rawQuery)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user