From 774ae238cb03e73306e40bb3109b517c956b77eb Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Sun, 7 Jul 2024 21:49:44 +0200 Subject: [PATCH] Implement SQL store --- pkg/storage/unified/resource/server.go | 2 +- .../unified/sql/data/resource_delete.sql | 7 + .../unified/sql/data/resource_insert.sql | 23 + .../unified/sql/data/resource_read.sql | 10 + .../unified/sql/data/resource_update.sql | 11 + pkg/storage/unified/sql/db/dbimpl/db.go | 59 ++ pkg/storage/unified/sql/db/dbimpl/dbEngine.go | 105 +++ .../unified/sql/db/dbimpl/dbEngine_test.go | 92 +++ pkg/storage/unified/sql/db/dbimpl/db_test.go | 154 ++++ pkg/storage/unified/sql/db/dbimpl/dbimpl.go | 166 ++++ pkg/storage/unified/sql/db/dbimpl/util.go | 111 +++ .../unified/sql/db/dbimpl/util_test.go | 108 +++ .../unified/sql/db/migrations/migrator.go | 24 + .../unified/sql/db/migrations/resource_mig.go | 101 +++ pkg/storage/unified/sql/db/service.go | 71 ++ pkg/storage/unified/sql/queries.go | 176 +++++ pkg/storage/unified/sql/queries_test.go | 584 ++++++++++++++ pkg/storage/unified/sql/sqltemplate/args.go | 85 +++ .../unified/sql/sqltemplate/args_test.go | 101 +++ .../unified/sql/sqltemplate/dialect.go | 140 ++++ .../unified/sql/sqltemplate/dialect_mysql.go | 21 + .../sql/sqltemplate/dialect_postgresql.go | 37 + .../sqltemplate/dialect_postgresql_test.go | 38 + .../unified/sql/sqltemplate/dialect_sqlite.go | 18 + .../unified/sql/sqltemplate/dialect_test.go | 184 +++++ .../unified/sql/sqltemplate/example_test.go | 156 ++++ pkg/storage/unified/sql/sqltemplate/into.go | 41 + .../unified/sql/sqltemplate/into_test.go | 51 ++ .../sql/sqltemplate/mocks/SQLTemplateIface.go | 664 ++++++++++++++++ .../sql/sqltemplate/mocks/WithResults.go | 719 ++++++++++++++++++ .../unified/sql/sqltemplate/sqltemplate.go | 141 ++++ .../sql/sqltemplate/sqltemplate_test.go | 91 +++ .../entity_folder_insert_1_mysql_sqlite.sql | 3 + .../entity_folder_insert_2_mysql_sqlite.sql | 5 + .../entity_history_read_full_mysql.sql | 5 + .../entity_labels_delete_1_mysql_sqlite.sql | 1 + .../entity_labels_delete_2_mysql_sqlite.sql | 1 + .../entity_labels_insert_1_mysql_sqlite.sql | 2 + .../entity_labels_insert_2_mysql_sqlite.sql | 3 + .../testdata/resource_delete_mysql_sqlite.sql | 1 + .../sql/testdata/resource_delete_postgres.sql | 1 + .../resource_history_insert_mysql_sqlite.sql | 3 + .../testdata/resource_insert_mysql_sqlite.sql | 3 + .../testdata/resource_read_mysql_sqlite.sql | 3 + .../testdata/resource_update_mysql_sqlite.sql | 2 + 45 files changed, 4323 insertions(+), 1 deletion(-) create mode 100644 pkg/storage/unified/sql/data/resource_delete.sql create mode 100644 pkg/storage/unified/sql/data/resource_insert.sql create mode 100644 pkg/storage/unified/sql/data/resource_read.sql create mode 100644 pkg/storage/unified/sql/data/resource_update.sql create mode 100644 pkg/storage/unified/sql/db/dbimpl/db.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/dbEngine.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/db_test.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/dbimpl.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/util.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/util_test.go create mode 100644 pkg/storage/unified/sql/db/migrations/migrator.go create mode 100644 pkg/storage/unified/sql/db/migrations/resource_mig.go create mode 100755 pkg/storage/unified/sql/db/service.go create mode 100644 pkg/storage/unified/sql/queries.go create mode 100644 pkg/storage/unified/sql/queries_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/args.go create mode 100644 pkg/storage/unified/sql/sqltemplate/args_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect_mysql.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect_postgresql.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect_postgresql_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect_sqlite.go create mode 100644 pkg/storage/unified/sql/sqltemplate/dialect_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/example_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/into.go create mode 100644 pkg/storage/unified/sql/sqltemplate/into_test.go create mode 100644 pkg/storage/unified/sql/sqltemplate/mocks/SQLTemplateIface.go create mode 100644 pkg/storage/unified/sql/sqltemplate/mocks/WithResults.go create mode 100644 pkg/storage/unified/sql/sqltemplate/sqltemplate.go create mode 100644 pkg/storage/unified/sql/sqltemplate/sqltemplate_test.go create mode 100644 pkg/storage/unified/sql/testdata/entity_folder_insert_1_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_folder_insert_2_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_history_read_full_mysql.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_labels_delete_1_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_labels_delete_2_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_labels_insert_1_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/entity_labels_insert_2_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_delete_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_delete_postgres.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_history_insert_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_insert_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_read_mysql_sqlite.sql create mode 100644 pkg/storage/unified/sql/testdata/resource_update_mysql_sqlite.sql diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index c873deb01ba..8b6833d257d 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -22,7 +22,7 @@ import ( // Package-level errors. var ( - ErrNotFound = errors.New("entity not found") + ErrNotFound = errors.New("resource not found") ErrOptimisticLockingFailed = errors.New("optimistic locking failed") ErrUserNotFoundInContext = errors.New("user not found in context") ErrUnableToReadResourceJSON = errors.New("unable to read resource json") diff --git a/pkg/storage/unified/sql/data/resource_delete.sql b/pkg/storage/unified/sql/data/resource_delete.sql new file mode 100644 index 00000000000..d28ae687f87 --- /dev/null +++ b/pkg/storage/unified/sql/data/resource_delete.sql @@ -0,0 +1,7 @@ +DELETE FROM {{ .Ident "resource" }} + WHERE 1 = 1 + AND {{ .Ident "namespace" }} = {{ .Arg .WriteEvent.Key.Namespace }} + AND {{ .Ident "group" }} = {{ .Arg .WriteEvent.Key.Group }} + AND {{ .Ident "resource" }} = {{ .Arg .WriteEvent.Key.Resource }} + AND {{ .Ident "name" }} = {{ .Arg .WriteEvent.Key.Name }} +; diff --git a/pkg/storage/unified/sql/data/resource_insert.sql b/pkg/storage/unified/sql/data/resource_insert.sql new file mode 100644 index 00000000000..e127901ae50 --- /dev/null +++ b/pkg/storage/unified/sql/data/resource_insert.sql @@ -0,0 +1,23 @@ +INSERT INTO {{ .Ident "resource" }} + + ( + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + + {{ .Ident "value" }}, + {{ .Ident "action" }} + ) + VALUES ( + {{ .Arg .GUID }}, + {{ .Arg .WriteEvent.Key.Group }}, + {{ .Arg .WriteEvent.Key.Resource }}, + {{ .Arg .WriteEvent.Key.Namespace }}, + {{ .Arg .WriteEvent.Key.Name }}, + + {{ .Arg .WriteEvent.Value }}, + {{ .Arg .WriteEvent.Type }} + ) +; diff --git a/pkg/storage/unified/sql/data/resource_read.sql b/pkg/storage/unified/sql/data/resource_read.sql new file mode 100644 index 00000000000..410bf5f1e74 --- /dev/null +++ b/pkg/storage/unified/sql/data/resource_read.sql @@ -0,0 +1,10 @@ +SELECT + {{ .Ident "resource_version" | .Into .ResourceVersion }}, + {{ .Ident "value" | .Into .Value }} + FROM {{ .Ident "resource" }} + WHERE 1 = 1 + AND {{ .Ident "namespace" }} = {{ .Arg .Request.Key.Namespace }} + AND {{ .Ident "group" }} = {{ .Arg .Request.Key.Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Request.Key.Resource }} + AND {{ .Ident "name" }} = {{ .Arg .Request.Key.Name }} +; diff --git a/pkg/storage/unified/sql/data/resource_update.sql b/pkg/storage/unified/sql/data/resource_update.sql new file mode 100644 index 00000000000..6f7fa2a49c0 --- /dev/null +++ b/pkg/storage/unified/sql/data/resource_update.sql @@ -0,0 +1,11 @@ +UPDATE {{ .Ident "resource" }} + SET + {{ .Ident "guid" }} = {{ .Arg .GUID }}, + {{ .Ident "value" }} = {{ .Arg .WriteEvent.Value }}, + {{ .Ident "action" }} = {{ .Arg .WriteEvent.Type }} + WHERE 1 = 1 + AND {{ .Ident "group" }} = {{ .Arg .WriteEvent.Key.Group }} + AND {{ .Ident "resource" }} = {{ .Arg .WriteEvent.Key.Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .WriteEvent.Key.Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .WriteEvent.Key.Name }} +; diff --git a/pkg/storage/unified/sql/db/dbimpl/db.go b/pkg/storage/unified/sql/db/dbimpl/db.go new file mode 100644 index 00000000000..625ddd9bcec --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/db.go @@ -0,0 +1,59 @@ +package dbimpl + +import ( + "context" + "database/sql" + "fmt" + + resourcedb "github.com/grafana/grafana/pkg/storage/unified/sql/db" +) + +func NewDB(d *sql.DB, driverName string) resourcedb.DB { + return sqldb{ + DB: d, + driverName: driverName, + } +} + +type sqldb struct { + *sql.DB + driverName string +} + +func (d sqldb) DriverName() string { + return d.driverName +} + +func (d sqldb) BeginTx(ctx context.Context, opts *sql.TxOptions) (resourcedb.Tx, error) { + t, err := d.DB.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + return tx{ + Tx: t, + }, nil +} + +func (d sqldb) WithTx(ctx context.Context, opts *sql.TxOptions, f resourcedb.TxFunc) error { + t, err := d.BeginTx(ctx, opts) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + if err := f(ctx, t); err != nil { + if rollbackErr := t.Rollback(); rollbackErr != nil { + return fmt.Errorf("tx err: %w; rollback err: %w", err, rollbackErr) + } + return fmt.Errorf("tx err: %w", err) + } + + if err = t.Commit(); err != nil { + return fmt.Errorf("commit err: %w", err) + } + + return nil +} + +type tx struct { + *sql.Tx +} diff --git a/pkg/storage/unified/sql/db/dbimpl/dbEngine.go b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go new file mode 100644 index 00000000000..5b1fb09462d --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go @@ -0,0 +1,105 @@ +package dbimpl + +import ( + "cmp" + "fmt" + "strings" + "time" + + "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(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: 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) + } + + if strings.HasPrefix(config.Addr, "/") { + config.Net = "unix" + } + + // FIXME: get rid of xorm + engine, err := xorm.NewEngine(db.DriverMySQL, config.FormatDSN()) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + engine.SetMaxOpenConns(0) + engine.SetMaxIdleConns(2) + engine.SetConnMaxLifetime(4 * time.Hour) + + return engine, nil +} + +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"), + } + + // TODO: probably interesting: + // "passfile", "statement_timeout", "lock_timeout", "connect_timeout" + + // 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 +} diff --git a/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go b/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go new file mode 100644 index 00000000000..8c3a77412c0 --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go @@ -0,0 +1,92 @@ +package dbimpl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetEngineMySQLFromConfig(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "/var/run/mysql.socket", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEngineMySQL(getter, nil) + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("invalid string", func(t *testing.T) { + t.Parallel() + + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "/var/run/mysql.socket", + "db_name": string(invalidUTF8ByteSequence), + "db_user": "user", + "db_password": "password", + }) + engine, err := getEngineMySQL(getter, nil) + assert.Nil(t, engine) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidUTF8Sequence) + }) +} + +func TestGetEnginePostgresFromConfig(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "localhost", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("invalid string", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": string(invalidUTF8ByteSequence), + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.Nil(t, engine) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidUTF8Sequence) + }) + + t.Run("invalid hostport", func(t *testing.T) { + t.Parallel() + getter := newTestSectionGetter(map[string]string{ + "db_type": "mysql", + "db_host": "1:1:1", + "db_name": "grafana", + "db_user": "user", + "db_password": "password", + }) + engine, err := getEnginePostgres(getter, nil) + + assert.Nil(t, engine) + assert.Error(t, err) + }) +} diff --git a/pkg/storage/unified/sql/db/dbimpl/db_test.go b/pkg/storage/unified/sql/db/dbimpl/db_test.go new file mode 100644 index 00000000000..7e7ff659bbb --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/db_test.go @@ -0,0 +1,154 @@ +package dbimpl + +import ( + "context" + "errors" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + + resourcedb "github.com/grafana/grafana/pkg/storage/unified/sql/db" +) + +func newCtx(t *testing.T) context.Context { + t.Helper() + + d, ok := t.Deadline() + if !ok { + // provide a default timeout for tests + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + return ctx + } + + ctx, cancel := context.WithDeadline(context.Background(), d) + t.Cleanup(cancel) + + return ctx +} + +var errTest = errors.New("because of reasons") + +const driverName = "sqlmock" + +func TestDB_BeginTx(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, driverName) + require.Equal(t, driverName, db.DriverName()) + + mock.ExpectBegin() + tx, err := db.BeginTx(newCtx(t), nil) + + require.NoError(t, err) + require.NotNil(t, tx) + }) + + t.Run("fail begin", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin().WillReturnError(errTest) + tx, err := db.BeginTx(newCtx(t), nil) + + require.Nil(t, tx) + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) +} + +func TestDB_WithTx(t *testing.T) { + t.Parallel() + + newTxFunc := func(err error) resourcedb.TxFunc { + return func(context.Context, resourcedb.Tx) error { + return err + } + } + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin() + mock.ExpectCommit() + err = db.WithTx(newCtx(t), nil, newTxFunc(nil)) + + require.NoError(t, err) + }) + + t.Run("fail begin", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(nil)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) + + t.Run("fail tx", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin() + mock.ExpectRollback() + err = db.WithTx(newCtx(t), nil, newTxFunc(errTest)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) + + t.Run("fail tx; fail rollback", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + errTest2 := errors.New("yet another err") + + mock.ExpectBegin() + mock.ExpectRollback().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(errTest2)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + require.ErrorIs(t, err, errTest2) + }) + + t.Run("fail commit", func(t *testing.T) { + t.Parallel() + + sqldb, mock, err := sqlmock.New() + require.NoError(t, err) + db := NewDB(sqldb, "sqlmock") + + mock.ExpectBegin() + mock.ExpectCommit().WillReturnError(errTest) + err = db.WithTx(newCtx(t), nil, newTxFunc(nil)) + + require.Error(t, err) + require.ErrorIs(t, err, errTest) + }) +} diff --git a/pkg/storage/unified/sql/db/dbimpl/dbimpl.go b/pkg/storage/unified/sql/db/dbimpl/dbimpl.go new file mode 100644 index 00000000000..11ef373692f --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/dbimpl.go @@ -0,0 +1,166 @@ +package dbimpl + +import ( + "fmt" + "sync" + + "github.com/dlmiddlecote/sqlstats" + "github.com/jmoiron/sqlx" + "github.com/prometheus/client_golang/prometheus" + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/session" + "github.com/grafana/grafana/pkg/setting" + resourcedb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/migrations" +) + +var _ resourcedb.ResourceDBInterface = (*ResourceDB)(nil) + +func ProvideResourceDB(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (*ResourceDB, error) { + return &ResourceDB{ + db: db, + cfg: cfg, + features: features, + log: log.New("entity-db"), + tracer: tracer, + }, nil +} + +type ResourceDB struct { + once sync.Once + onceErr error + + db db.DB + features featuremgmt.FeatureToggles + engine *xorm.Engine + cfg *setting.Cfg + log log.Logger + tracer tracing.Tracer +} + +func (db *ResourceDB) Init() error { + db.once.Do(func() { + db.onceErr = db.init() + }) + + return db.onceErr +} + +func (db *ResourceDB) GetEngine() (*xorm.Engine, error) { + if err := db.Init(); err != nil { + return nil, err + } + + return db.engine, db.onceErr +} + +func (db *ResourceDB) init() error { + if db.engine != nil { + return nil + } + + var engine *xorm.Engine + var err error + + // TODO: This should be renamed resource_api + 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(getter, db.tracer) + if err != nil { + 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(getter, db.tracer) + if err != nil { + return err + } + + if err = engine.Ping(); err != nil { + return err + } + } else { + // TODO: sqlite support + return fmt.Errorf("invalid db type specified: %s", dbType) + } + + // register sql stat metrics + if err := prometheus.Register(sqlstats.NewStatsCollector("unified_storage", engine.DB().DB)); err != nil { + db.log.Warn("Failed to register unified storage sql stats collector", "error", err) + } + + // configure sql logging + debugSQL := getter.Key("log_queries").MustBool(false) + if !debugSQL { + engine.SetLogger(&xorm.DiscardLogger{}) + } else { + // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. + // engine.SetLogger(sqlstore.NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("sqlstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) + engine.ShowSQL(true) + engine.ShowExecTime(true) + } + + // otherwise, try to use the grafana db connection + } else { + if db.db == nil { + return fmt.Errorf("no db connection provided") + } + + engine = db.db.GetEngine() + } + + db.engine = engine + + if err := migrations.MigrateResourceStore(engine, db.cfg, db.features); err != nil { + db.engine = nil + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +func (db *ResourceDB) GetSession() (*session.SessionDB, error) { + engine, err := db.GetEngine() + if err != nil { + return nil, err + } + + return session.GetSession(sqlx.NewDb(engine.DB().DB, engine.DriverName())), nil +} + +func (db *ResourceDB) GetCfg() *setting.Cfg { + return db.cfg +} + +func (db *ResourceDB) GetDB() (resourcedb.DB, error) { + engine, err := db.GetEngine() + if err != nil { + return nil, err + } + + ret := NewDB(engine.DB().DB, engine.Dialect().DriverName()) + + return ret, nil +} diff --git a/pkg/storage/unified/sql/db/dbimpl/util.go b/pkg/storage/unified/sql/db/dbimpl/util.go new file mode 100644 index 00000000000..44ff1138236 --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/util.go @@ -0,0 +1,111 @@ +package dbimpl + +import ( + "cmp" + "errors" + "fmt" + "net" + "sort" + "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 { + 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) + + ks := keys(m) + sort.Strings(ks) // provide deterministic behaviour + for _, k := range ks { + v := m[k] + if !utf8.ValidString(v) { + return "", fmt.Errorf("value for DSN key %q: %w", k, + ErrInvalidUTF8Sequence) + } + if v == "" { + continue + } + + if b.Len() > 0 { + _ = b.WriteByte(' ') + } + _, _ = b.WriteString(k) + _ = b.WriteByte('=') + writeDSNValue(b, v) + } + + return b.String(), nil +} + +func keys(m map[string]string) []string { + ret := make([]string, 0, len(m)) + for k := range m { + ret = append(ret, k) + } + return ret +} + +func writeDSNValue(b *strings.Builder, v string) { + numq := strings.Count(v, `'`) + numb := strings.Count(v, `\`) + 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('\'') +} + +// splitHostPortDefault is similar to net.SplitHostPort, but will also accept a +// specification with no port and apply the default port instead. It also +// applies the given defaults if the results are empty strings. +func splitHostPortDefault(hostport, defaultHost, defaultPort string) (string, string, error) { + host, port, err := net.SplitHostPort(hostport) + if err != nil { + // 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 +} diff --git a/pkg/storage/unified/sql/db/dbimpl/util_test.go b/pkg/storage/unified/sql/db/dbimpl/util_test.go new file mode 100644 index 00000000000..cb3a76aeee7 --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/util_test.go @@ -0,0 +1,108 @@ +package dbimpl + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +var invalidUTF8ByteSequence = []byte{0xff, 0xfe, 0xfd} + +func setSectionKeyValues(section *setting.DynamicSection, m map[string]string) { + for k, v := range m { + section.Key(k).SetValue(v) + } +} + +func newTestSectionGetter(m map[string]string) *sectionGetter { + section := setting.NewCfg().SectionWithEnvOverrides("entity_api") + setSectionKeyValues(section, m) + + return §ionGetter{ + DynamicSection: section, + } +} + +func TestSectionGetter(t *testing.T) { + t.Parallel() + + var ( + key = "the key" + val = string(invalidUTF8ByteSequence) + ) + + g := newTestSectionGetter(map[string]string{ + key: val, + }) + + v := g.String("whatever") + require.Empty(t, v) + require.NoError(t, g.Err()) + + v = g.String(key) + require.Empty(t, v) + require.Error(t, g.Err()) + require.ErrorIs(t, g.Err(), ErrInvalidUTF8Sequence) +} + +func TestMakeDSN(t *testing.T) { + t.Parallel() + + s, err := MakeDSN(map[string]string{ + "db_name": string(invalidUTF8ByteSequence), + }) + require.Empty(t, s) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidUTF8Sequence) + + s, err = MakeDSN(map[string]string{ + "skip": "", + "user": `shou'ld esc\ape`, + "pass": "noescape", + }) + require.NoError(t, err) + require.Equal(t, `pass=noescape user='shou\'ld esc\\ape'`, s) +} + +func TestSplitHostPort(t *testing.T) { + t.Parallel() + + testCases := []struct { + hostport string + defaultHost string + defaultPort string + fails bool + + host string + port string + }{ + {hostport: "192.168.0.140:456", defaultHost: "", defaultPort: "", host: "192.168.0.140", port: "456"}, + {hostport: "192.168.0.140", defaultHost: "", defaultPort: "123", host: "192.168.0.140", port: "123"}, + {hostport: "[::1]:456", defaultHost: "", defaultPort: "", host: "::1", port: "456"}, + {hostport: "[::1]", defaultHost: "", defaultPort: "123", host: "::1", port: "123"}, + {hostport: ":456", defaultHost: "1.2.3.4", defaultPort: "", host: "1.2.3.4", port: "456"}, + {hostport: "xyz.rds.amazonaws.com", defaultHost: "", defaultPort: "123", host: "xyz.rds.amazonaws.com", port: "123"}, + {hostport: "xyz.rds.amazonaws.com:123", defaultHost: "", defaultPort: "", host: "xyz.rds.amazonaws.com", port: "123"}, + {hostport: "", defaultHost: "localhost", defaultPort: "1433", host: "localhost", port: "1433"}, + {hostport: "1:1:1", fails: true}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test index #%d", i), func(t *testing.T) { + t.Parallel() + + host, port, err := splitHostPortDefault(tc.hostport, tc.defaultHost, tc.defaultPort) + if tc.fails { + require.Error(t, err) + require.Empty(t, host) + require.Empty(t, port) + } else { + require.NoError(t, err) + require.Equal(t, tc.host, host) + require.Equal(t, tc.port, port) + } + }) + } +} diff --git a/pkg/storage/unified/sql/db/migrations/migrator.go b/pkg/storage/unified/sql/db/migrations/migrator.go new file mode 100644 index 00000000000..f362342639f --- /dev/null +++ b/pkg/storage/unified/sql/db/migrations/migrator.go @@ -0,0 +1,24 @@ +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/setting" +) + +func MigrateResourceStore(engine *xorm.Engine, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { + // Skip if feature flag is not enabled + if !features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorage) { + return nil + } + + mg := migrator.NewScopedMigrator(engine, cfg, "resource") + mg.AddCreateMigration() + + initResourceTables(mg) + + // since it's a new feature enable migration locking by default + return mg.Start(true, 0) +} diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go new file mode 100644 index 00000000000..af68f4be60b --- /dev/null +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -0,0 +1,101 @@ +package migrations + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func initResourceTables(mg *migrator.Migrator) string { + marker := "Initialize resource tables" + mg.AddMigration(marker, &migrator.RawSQLMigration{}) + + tables := []migrator.Table{} + tables = append(tables, migrator.Table{ + Name: "resource", + Columns: []*migrator.Column{ + // primary identifier + {Name: "guid", Type: migrator.DB_NVarchar, Length: 36, Nullable: false, IsPrimaryKey: true}, + + {Name: "resource_version", Type: migrator.DB_BigInt, Nullable: true}, + + // K8s Identity group+(version)+namespace+resource+name + {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: false}, + {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "value", Type: migrator.DB_LongText, Nullable: true}, + {Name: "action", Type: migrator.DB_Int, Nullable: false}, // 1: create, 2: update, 3: delete + + // Hashed label set + {Name: "label_set", Type: migrator.DB_NVarchar, Length: 64, Nullable: true}, // null is no labels + }, + Indices: []*migrator.Index{ + {Cols: []string{"namespace", "group", "resource", "name"}, Type: migrator.UniqueIndex}, + }, + }) + + tables = append(tables, migrator.Table{ + Name: "resource_history", + Columns: []*migrator.Column{ + // primary identifier + {Name: "guid", Type: migrator.DB_NVarchar, Length: 36, Nullable: false, IsPrimaryKey: true}, + {Name: "resource_version", Type: migrator.DB_BigInt, Nullable: true}, + + // K8s Identity group+(version)+namespace+resource+name + {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: false}, + {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "value", Type: migrator.DB_LongText, Nullable: true}, + {Name: "action", Type: migrator.DB_Int, Nullable: false}, // 1: create, 2: update, 3: delete + + // Hashed label set + {Name: "label_set", Type: migrator.DB_NVarchar, Length: 64, Nullable: true}, // null is no labels + }, + Indices: []*migrator.Index{ + { + Cols: []string{"namespace", "group", "resource", "name", "resource_version"}, + Type: migrator.UniqueIndex, + Name: "UQE_resource_history_namespace_group_name_version", + }, + // index to support watch poller + {Cols: []string{"resource_version"}, Type: migrator.IndexType}, + }, + }) + + tables = append(tables, migrator.Table{ + Name: "resource_label_set", + Columns: []*migrator.Column{ + {Name: "label_set", Type: migrator.DB_NVarchar, Length: 64, Nullable: false}, + {Name: "label", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "value", Type: migrator.DB_Text, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"label_set", "label"}, Type: migrator.UniqueIndex}, + }, + }) + + tables = append(tables, migrator.Table{ + Name: "resource_version", + Columns: []*migrator.Column{ + {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "resource_version", Type: migrator.DB_BigInt, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex}, + }, + }) + + // Initialize all tables + for t := range tables { + mg.AddMigration("drop table "+tables[t].Name, migrator.NewDropTableMigration(tables[t].Name)) + mg.AddMigration("create table "+tables[t].Name, migrator.NewAddTableMigration(tables[t])) + for i := range tables[t].Indices { + mg.AddMigration(fmt.Sprintf("create table %s, index: %d", tables[t].Name, i), migrator.NewAddIndexMigration(tables[t], tables[t].Indices[i])) + } + } + + return marker +} diff --git a/pkg/storage/unified/sql/db/service.go b/pkg/storage/unified/sql/db/service.go new file mode 100755 index 00000000000..aee367fae7c --- /dev/null +++ b/pkg/storage/unified/sql/db/service.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "database/sql" + + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/services/sqlstore/session" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + DriverPostgres = "postgres" + DriverMySQL = "mysql" + DriverSQLite = "sqlite" + DriverSQLite3 = "sqlite3" +) + +// ResourceDBInterface provides access to a database capable of supporting the +// Entity Server. +type ResourceDBInterface interface { + Init() error + GetCfg() *setting.Cfg + GetDB() (DB, error) + + // TODO: deprecate. + GetSession() (*session.SessionDB, error) + GetEngine() (*xorm.Engine, error) +} + +// DB is a thin abstraction on *sql.DB to allow mocking to provide better unit +// testing. We purposefully hide database operation methods that would use +// context.Background(). +type DB interface { + ContextExecer + BeginTx(context.Context, *sql.TxOptions) (Tx, error) + WithTx(context.Context, *sql.TxOptions, TxFunc) error + PingContext(context.Context) error + Stats() sql.DBStats + 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 + 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) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go new file mode 100644 index 00000000000..17f26a53f2a --- /dev/null +++ b/pkg/storage/unified/sql/queries.go @@ -0,0 +1,176 @@ +package sql + +import ( + "database/sql" + "embed" + "fmt" + "strings" + "text/template" + + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +// Templates setup. +var ( + //go:embed data/*.sql + sqlTemplatesFS embed.FS + + // all templates + helpers = template.FuncMap{ + "listSep": helperListSep, + "join": helperJoin, + } + sqlTemplates = template.Must(template.New("sql").Funcs(helpers).ParseFS(sqlTemplatesFS, `data/*.sql`)) +) + +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 ( + sqlResourceDelete = mustTemplate("resource_delete.sql") + sqlResourceInsert = mustTemplate("resource_insert.sql") + sqlResourceUpdate = mustTemplate("resource_update.sql") + sqlResourceRead = mustTemplate("resource_read.sql") + sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql") + sqlResourceHistoryUpdateRV = mustTemplate("resource_history_update_rv.sql") + sqlResourceHistoryInsert = mustTemplate("resource_history_insert.sql") + + // sqlResourceLabelsInsert = mustTemplate("resource_labels_insert.sql") + sqlResourceVersionGet = mustTemplate("resource_version_get.sql") + sqlResourceVersionInc = mustTemplate("resource_version_inc.sql") + sqlResourceVersionInsert = mustTemplate("resource_version_insert.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 + TemplateName string + Query string + RawQuery string + ScanDest []any + + // potentially regulated information is not exported and only directly + // available for local testing and local debugging purposes, making sure it + // is never marshaled to JSON or any other serialization. + + arguments []any +} + +func (e SQLError) Unwrap() error { + return e.Err +} + +func (e SQLError) Error() string { + return fmt.Sprintf("%s: %s with %d input arguments and %d output "+ + "destination arguments: %v", e.TemplateName, e.CallType, + len(e.arguments), len(e.ScanDest), e.Err) +} + +type sqlResourceRequest struct { + *sqltemplate.SQLTemplate + GUID string + WriteEvent resource.WriteEvent +} + +func (r sqlResourceRequest) Validate() error { + return nil // TODO +} + +// sqlResourceReadRequest can be used to retrieve a row from either the "resource" +// or the "resource_history" tables. + +type readResponse struct { + resource.ReadResponse +} + +func (r *readResponse) Results() (*readResponse, error) { + return r, nil +} + +type sqlResourceReadRequest struct { + *sqltemplate.SQLTemplate + Request *resource.ReadRequest + *readResponse +} + +func (r sqlResourceReadRequest) Validate() error { + return nil // TODO +} + +// update RV + +type sqlResourceUpdateRVRequest struct { + *sqltemplate.SQLTemplate + GUID string + ResourceVersion int64 +} + +func (r sqlResourceUpdateRVRequest) Validate() error { + return nil // TODO +} + +// resource_version table requests. +type resourceVersion struct { + ResourceVersion int64 +} + +func (r *resourceVersion) Results() (*resourceVersion, error) { + return r, nil +} + +type sqlResourceVersionRequest struct { + *sqltemplate.SQLTemplate + Key *resource.ResourceKey + *resourceVersion +} + +func (r sqlResourceVersionRequest) Validate() error { + return nil // TODO +} + +// 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) +} diff --git a/pkg/storage/unified/sql/queries_test.go b/pkg/storage/unified/sql/queries_test.go new file mode 100644 index 00000000000..62043e08e09 --- /dev/null +++ b/pkg/storage/unified/sql/queries_test.go @@ -0,0 +1,584 @@ +package sql + +import ( + "embed" + "encoding/json" + "errors" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +// debug is meant to provide greater debugging detail about certain errors. The +// returned error will either provide more detailed information or be the same +// original error, suitable only for local debugging. The details provided are +// not meant to be logged, since they could include PII or otherwise +// sensitive/confidential information. These information should only be used for +// local debugging with fake or otherwise non-regulated information. +func debug(err error) error { + var d interface{ Debug() string } + if errors.As(err, &d) { + return errors.New(d.Debug()) + } + + return err +} + +var _ = debug // silence the `unused` linter + +//go:embed testdata/* +var testdataFS embed.FS + +func testdata(t *testing.T, filename string) []byte { + t.Helper() + b, err := testdataFS.ReadFile(`testdata/` + filename) + require.NoError(t, err) + + return b +} + +func testdataJSON(t *testing.T, filename string, dest any) { + t.Helper() + b := testdata(t, filename) + err := json.Unmarshal(b, dest) + require.NoError(t, err) +} + +func TestQueries(t *testing.T) { + t.Parallel() + + // Each template has one or more test cases, each identified with a + // descriptive name (e.g. "happy path", "error twiddling the frobb"). Each + // of them will test that for the same input data they must produce a result + // that will depend on the Dialect. Expected queries should be defined in + // separate files in the testdata directory. This improves the testing + // experience by separating test data from test code, since mixing both + // tends to make it more difficult to reason about what is being done, + // especially as we want testing code to scale and make it easy to add + // tests. + type ( + // type aliases to make code more semantic and self-documenting + resultSQLFilename = string + dialects = []sqltemplate.Dialect + expected map[resultSQLFilename]dialects + + testCase = struct { + Name string + + // Data should be the struct passed to the template. + Data sqltemplate.SQLTemplateIface + + // Expected maps the filename containing the expected result query + // to the list of dialects that would produce it. For simple + // queries, it is possible that more than one dialect produce the + // same output. The filename is expected to be in the `testdata` + // directory. + Expected expected + } + ) + + // Define tests cases. Most templates are trivial and testing that they + // generate correct code for a single Dialect is fine, since the one thing + // that always changes is how SQL placeholder arguments are passed (most + // Dialects use `?` while PostgreSQL uses `$1`, `$2`, etc.), and that is + // something that should be tested in the Dialect implementation instead of + // here. We will ask to have at least one test per SQL template, and we will + // lean to test MySQL. Templates containing branching (conditionals, loops, + // etc.) should be exercised at least once in each of their branches. + // + // NOTE: in the Data field, make sure to have pointers populated to simulate + // data is set as it would be in a real request. The data being correctly + // populated in each case should be tested in integration tests, where the + // data will actually flow to and from a real database. In this tests we + // only care about producing the correct SQL. + testCases := map[*template.Template][]*testCase{ + sqlResourceDelete: { + { + Name: "single path", + Data: &sqlResourceRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + WriteEvent: resource.WriteEvent{ + Key: &resource.ResourceKey{}, + }, + }, + Expected: expected{ + "resource_delete_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + "resource_delete_postgres.sql": dialects{ + sqltemplate.PostgreSQL, + }, + }, + }, + }, + + sqlResourceInsert: { + { + Name: "insert into resource", + Data: &sqlResourceRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + WriteEvent: resource.WriteEvent{ + Key: &resource.ResourceKey{}, + }, + }, + Expected: expected{ + "resource_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + sqlResourceUpdate: { + { + Name: "single path", + Data: &sqlResourceRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + WriteEvent: resource.WriteEvent{ + Key: &resource.ResourceKey{}, + }, + }, + Expected: expected{ + "resource_update_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlResourceRead: { + { + Name: "without resource version", + Data: &sqlResourceReadRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Request: &resource.ReadRequest{ + Key: &resource.ResourceKey{}, + }, + readResponse: new(readResponse), + }, + Expected: expected{ + "resource_read_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + sqlResourceUpdateRV: { + { + Name: "single path", + Data: &sqlResourceUpdateRVRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + }, + Expected: expected{ + "resource_update_rv_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + sqlResourceHistoryUpdateRV: { + { + Name: "single path", + Data: &sqlResourceUpdateRVRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + }, + Expected: expected{ + "resource_history_update_rv_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + sqlResourceHistoryInsert: { + { + Name: "insert into entity_history", + Data: &sqlResourceRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + WriteEvent: resource.WriteEvent{ + Key: &resource.ResourceKey{}, + }, + }, + Expected: expected{ + "resource_history_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlResourceVersionGet: { + { + Name: "single path", + Data: &sqlResourceVersionRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: &resource.ResourceKey{}, + resourceVersion: new(resourceVersion), + }, + Expected: expected{ + "resource_version_get_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlResourceVersionInc: { + { + Name: "increment resource version", + Data: &sqlResourceVersionRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: &resource.ResourceKey{}, + }, + Expected: expected{ + "resource_version_inc_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + + sqlResourceVersionInsert: { + { + Name: "single path", + Data: &sqlResourceVersionRequest{ + SQLTemplate: new(sqltemplate.SQLTemplate), + Key: &resource.ResourceKey{}, + }, + Expected: expected{ + "resource_version_insert_mysql_sqlite.sql": dialects{ + sqltemplate.MySQL, + sqltemplate.SQLite, + }, + }, + }, + }, + } + + // Execute test cases + for tmpl, tcs := range testCases { + t.Run(tmpl.Name(), func(t *testing.T) { + t.Parallel() + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + for filename, ds := range tc.Expected { + t.Run(filename, func(t *testing.T) { + // not parallel because we're sharing tc.Data, not + // worth it deep cloning + + rawQuery := string(testdata(t, filename)) + expectedQuery := sqltemplate.FormatSQL(rawQuery) + + for _, d := range ds { + t.Run(d.Name(), func(t *testing.T) { + // not parallel for the same reason + + tc.Data.SetDialect(d) + err := tc.Data.Validate() + require.NoError(t, err) + got, err := sqltemplate.Execute(tmpl, tc.Data) + require.NoError(t, err) + got = sqltemplate.FormatSQL(got) + require.Equal(t, expectedQuery, got) + }) + } + }) + } + }) + } + }) + } +} + +// func TestReadEntity(t *testing.T) { +// t.Parallel() + +// // readonly, shared data for all subtests +// expectedEntity := newEmptyEntity() +// testdataJSON(t, `grpc-res-entity.json`, expectedEntity) +// key, err := grafanaregistry.ParseKey(expectedEntity.Key) +// require.NoErrorf(t, err, "provided key: %#v", expectedEntity) + +// t.Run("happy path - entity table, optimistic locking", func(t *testing.T) { +// t.Parallel() + +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// x := expectReadEntity(t, mock, cloneEntity(expectedEntity)) +// x(ctx, db) +// }) + +// t.Run("happy path - entity table, no optimistic locking", func(t *testing.T) { +// t.Parallel() + +// // test declarations +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// readReq := sqlEntityReadRequest{ // used to generate mock results +// SQLTemplate: sqltemplate.New(sqltemplate.MySQL), +// Key: new(grafanaregistry.Key), +// returnsEntitySet: newReturnsEntitySet(), +// } +// readReq.Entity.Entity = cloneEntity(expectedEntity) +// results := newMockResults(t, mock, sqlEntityRead, readReq) + +// // setup expectations +// results.AddCurrentData() +// mock.ExpectQuery(`select from entity where !resource_version update`). +// WillReturnRows(results.Rows()) + +// // execute and assert +// e, err := readEntity(ctx, db, sqltemplate.MySQL, key, 0, false, true) +// require.NoError(t, err) +// require.Equal(t, expectedEntity, e.Entity) +// }) + +// t.Run("happy path - entity_history table", func(t *testing.T) { +// t.Parallel() + +// // test declarations +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// readReq := sqlEntityReadRequest{ // used to generate mock results +// SQLTemplate: sqltemplate.New(sqltemplate.MySQL), +// Key: new(grafanaregistry.Key), +// returnsEntitySet: newReturnsEntitySet(), +// } +// readReq.Entity.Entity = cloneEntity(expectedEntity) +// results := newMockResults(t, mock, sqlEntityRead, readReq) + +// // setup expectations +// results.AddCurrentData() +// mock.ExpectQuery(`select from entity_history where resource_version !update`). +// WillReturnRows(results.Rows()) + +// // execute and assert +// e, err := readEntity(ctx, db, sqltemplate.MySQL, key, +// expectedEntity.ResourceVersion, false, false) +// require.NoError(t, err) +// require.Equal(t, expectedEntity, e.Entity) +// }) + +// t.Run("entity table, optimistic locking failed", func(t *testing.T) { +// t.Parallel() + +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// x := expectReadEntity(t, mock, nil) +// x(ctx, db) +// }) + +// t.Run("entity_history table, entity not found", func(t *testing.T) { +// t.Parallel() + +// // test declarations +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// readReq := sqlEntityReadRequest{ // used to generate mock results +// SQLTemplate: sqltemplate.New(sqltemplate.MySQL), +// Key: new(grafanaregistry.Key), +// returnsEntitySet: newReturnsEntitySet(), +// } +// results := newMockResults(t, mock, sqlEntityRead, readReq) + +// // setup expectations +// mock.ExpectQuery(`select from entity_history where resource_version !update`). +// WillReturnRows(results.Rows()) + +// // execute and assert +// e, err := readEntity(ctx, db, sqltemplate.MySQL, key, +// expectedEntity.ResourceVersion, false, false) +// require.Nil(t, e) +// require.Error(t, err) +// require.ErrorIs(t, err, ErrNotFound) +// }) + +// t.Run("entity_history table, entity was deleted = not found", func(t *testing.T) { +// t.Parallel() + +// // test declarations +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// readReq := sqlEntityReadRequest{ // used to generate mock results +// SQLTemplate: sqltemplate.New(sqltemplate.MySQL), +// Key: new(grafanaregistry.Key), +// returnsEntitySet: newReturnsEntitySet(), +// } +// readReq.Entity.Entity = cloneEntity(expectedEntity) +// readReq.Entity.Entity.Action = entity.Entity_DELETED +// results := newMockResults(t, mock, sqlEntityRead, readReq) + +// // setup expectations +// results.AddCurrentData() +// mock.ExpectQuery(`select from entity_history where resource_version !update`). +// WillReturnRows(results.Rows()) + +// // execute and assert +// e, err := readEntity(ctx, db, sqltemplate.MySQL, key, +// expectedEntity.ResourceVersion, false, false) +// require.Nil(t, e) +// require.Error(t, err) +// require.ErrorIs(t, err, ErrNotFound) +// }) +// } + +// // expectReadEntity arranges test expectations so that it's easier to reuse +// // across tests that need to call `readEntity`. If you provide a non-nil +// // *entity.Entity, that will be returned by `readEntity`. If it's nil, then +// // `readEntity` will return ErrOptimisticLockingFailed. It returns the function +// // to execute the actual test and assert the expectations that were set. +// func expectReadEntity(t *testing.T, mock sqlmock.Sqlmock, e *entity.Entity) func(ctx context.Context, db db.DB) { +// t.Helper() + +// // test declarations +// readReq := sqlEntityReadRequest{ // used to generate mock results +// SQLTemplate: sqltemplate.New(sqltemplate.MySQL), +// Key: new(grafanaregistry.Key), +// returnsEntitySet: newReturnsEntitySet(), +// } +// results := newMockResults(t, mock, sqlEntityRead, readReq) +// if e != nil { +// readReq.Entity.Entity = cloneEntity(e) +// } + +// // setup expectations +// results.AddCurrentData() +// mock.ExpectQuery(`select from entity where !resource_version update`). +// WillReturnRows(results.Rows()) + +// // execute and assert +// if e != nil { +// return func(ctx context.Context, db db.DB) { +// ent, err := readEntity(ctx, db, sqltemplate.MySQL, readReq.Key, +// e.ResourceVersion, true, true) +// require.NoError(t, err) +// require.Equal(t, e, ent.Entity) +// } +// } + +// return func(ctx context.Context, db db.DB) { +// ent, err := readEntity(ctx, db, sqltemplate.MySQL, readReq.Key, 1, true, +// true) +// require.Nil(t, ent) +// require.Error(t, err) +// require.ErrorIs(t, err, ErrOptimisticLockingFailed) +// } +// } + +// func TestKindVersionAtomicInc(t *testing.T) { +// t.Parallel() + +// t.Run("happy path - row locked", func(t *testing.T) { +// t.Parallel() + +// // test declarations +// const curVersion int64 = 1 +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) + +// // setup expectations +// mock.ExpectQuery(`select resource_version from resource_version where group resource update`). +// WillReturnRows(mock.NewRows([]string{"resource_version"}).AddRow(curVersion)) +// mock.ExpectExec("update resource_version set resource_version updated_at where group resource"). +// WillReturnResult(sqlmock.NewResult(0, 1)) + +// // execute and assert +// gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") +// require.NoError(t, err) +// require.Equal(t, curVersion+1, gotVersion) +// }) + +// t.Run("happy path - row created", func(t *testing.T) { +// t.Parallel() + +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// x := expectKindVersionAtomicInc(t, mock, false) +// x(ctx, db) +// }) + +// t.Run("fail to create row", func(t *testing.T) { +// t.Parallel() + +// ctx := testutil.NewDefaultTestContext(t) +// db, mock := newMockDBMatchWords(t) +// x := expectKindVersionAtomicInc(t, mock, true) +// x(ctx, db) +// }) +// } + +// // expectKindVersionAtomicInc arranges test expectations so that it's easier to +// // reuse across tests that need to call `kindVersionAtomicInc`. If you the test +// // shuld fail, it will do so with `errTest`, and it will return resource version +// // 1 otherwise. It returns the function to execute the actual test and assert +// // the expectations that were set. +// func expectKindVersionAtomicInc(t *testing.T, mock sqlmock.Sqlmock, shouldFail bool) func(ctx context.Context, db db.DB) { +// t.Helper() + +// // setup expectations +// mock.ExpectQuery(`select resource_version from resource_version where group resource update`). +// WillReturnRows(mock.NewRows([]string{"resource_version"})) +// call := mock.ExpectExec("insert resource_version resource_version") + +// // execute and assert +// if shouldFail { +// call.WillReturnError(errTest) + +// return func(ctx context.Context, db db.DB) { +// gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") +// require.Zero(t, gotVersion) +// require.Error(t, err) +// require.ErrorIs(t, err, errTest) +// } +// } +// call.WillReturnResult(sqlmock.NewResult(0, 1)) + +// return func(ctx context.Context, db db.DB) { +// gotVersion, err := kindVersionAtomicInc(ctx, db, sqltemplate.MySQL, "groupname", "resname") +// require.NoError(t, err) +// require.Equal(t, int64(1), gotVersion) +// } +// } + +// func TestMustTemplate(t *testing.T) { +// t.Parallel() + +// require.Panics(t, func() { +// mustTemplate("non existent file") +// }) +// } + +// // Debug provides greater detail about the SQL error. It is defined on the same +// // struct but on a test file so that the intention that its results should not +// // be used in runtime code is very clear. The results could include PII or +// // otherwise regulated information, hence this method is only available in +// // tests, so that it can be used in local debugging only. Note that the error +// // information may still be available through other means, like using the +// // "reflect" package, so care must be taken not to ever expose these information +// // in production. +// func (e SQLError) Debug() string { +// scanDestStr := "(none)" +// if len(e.ScanDest) > 0 { +// format := "[%T" + strings.Repeat(", %T", len(e.ScanDest)-1) + "]" +// scanDestStr = fmt.Sprintf(format, e.ScanDest...) +// } + +// return fmt.Sprintf("%s: %s: %v\n\tArguments (%d): %#v\n\tReturn Value "+ +// "Types (%d): %s\n\tExecuted Query: %s\n\tRaw SQL Template Output: %s", +// e.TemplateName, e.CallType, e.Err, len(e.arguments), e.arguments, +// len(e.ScanDest), scanDestStr, e.Query, e.RawQuery) +// } diff --git a/pkg/storage/unified/sql/sqltemplate/args.go b/pkg/storage/unified/sql/sqltemplate/args.go new file mode 100644 index 00000000000..eac4452ea37 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/args.go @@ -0,0 +1,85 @@ +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 struct { + d interface{ ArgPlaceholder(argNum int) string } + 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.values = append(a.values, x) + + return a.d.ArgPlaceholder(len(a.values)) +} + +// 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) Reset() { + a.values = nil +} + +func (a *Args) GetArgs() []any { + return a.values +} + +type ArgsIface interface { + Arg(x any) string + ArgList(slice reflect.Value) (string, error) + GetArgs() []any + Reset() +} diff --git a/pkg/storage/unified/sql/sqltemplate/args_test.go b/pkg/storage/unified/sql/sqltemplate/args_test.go new file mode 100644 index 00000000000..5399db21ae1 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/args_test.go @@ -0,0 +1,101 @@ +package sqltemplate + +import ( + "errors" + "reflect" + "testing" +) + +func TestArgs_Arg(t *testing.T) { + t.Parallel() + + shouldBeQuestionMark := func(t *testing.T, s string) { + t.Helper() + if s != "?" { + t.Fatalf("expecting question mark, got %q", s) + } + } + + a := NewArgs(MySQL) + + shouldBeQuestionMark(t, a.Arg(0)) + shouldBeQuestionMark(t, a.Arg(1)) + shouldBeQuestionMark(t, a.Arg(2)) + shouldBeQuestionMark(t, a.Arg(3)) + shouldBeQuestionMark(t, a.Arg(4)) + + for i, arg := range a.GetArgs() { + v, ok := arg.(int) + if !ok { + t.Fatalf("unexpected value: %T(%v)", arg, arg) + } + if v != i { + t.Fatalf("unexpected int value: %v", v) + } + } +} + +func TestArg_ArgList(t *testing.T) { + t.Parallel() + + testCases := []struct { + input reflect.Value + added []any + output string + err error + }{ + {err: ErrInvalidArgList}, + {input: reflect.ValueOf(1), err: ErrInvalidArgList}, + {input: reflect.ValueOf(nil), err: ErrInvalidArgList}, + {input: reflect.ValueOf(any(nil)), err: ErrInvalidArgList}, + {input: reflect.ValueOf("asd"), err: ErrInvalidArgList}, + {input: reflect.ValueOf([]any{})}, + + { + input: reflect.ValueOf([]any{true}), + added: []any{true}, + output: "?", + }, + + { + input: reflect.ValueOf([]any{1, true}), + added: []any{1, true}, + output: "?, ?", + }, + + { + input: reflect.ValueOf([]any{1, "asd", true}), + added: []any{1, "asd", true}, + output: "?, ?, ?", + }, + } + + var a Args + a.d = argFmtSQL92 + for i, tc := range testCases { + a.Reset() + + gotOutput, gotErr := a.ArgList(tc.input) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("[test #%d] Unexpected error. Expected: %v, actual: %v", + i, gotErr, tc.err) + } + + if tc.output != gotOutput { + t.Fatalf("[test #%d] Unexpected output. Expected: %v, actual: %v", + i, gotOutput, tc.output) + } + + if len(tc.added) != len(a.values) { + t.Fatalf("[test #%d] Unexpected added items.\n\tExpected: %#v\n\t"+ + "Actual: %#v", i, tc.added, a.values) + } + + for j := range tc.added { + if !reflect.DeepEqual(tc.added[j], a.values[j]) { + t.Fatalf("[test #%d] Unexpected %d-eth item.\n\tExpected:"+ + " %#v\n\tActual: %#v", i, j, tc.added[j], a.values[j]) + } + } + } +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect.go b/pkg/storage/unified/sql/sqltemplate/dialect.go new file mode 100644 index 00000000000..2824d783059 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect.go @@ -0,0 +1,140 @@ +package sqltemplate + +import ( + "errors" + "strconv" + "strings" +) + +// Dialect-agnostic errors. +var ( + ErrEmptyIdent = errors.New("empty identifier") + ErrInvalidRowLockingClause = errors.New("invalid row-locking clause") +) + +// Dialect should be added to the data types passed to SQL templates to +// provide methods that deal with SQL implementation-specific traits. It can be +// embedded for ease of use, or with a named struct field if any of its methods +// would clash with other struct field names. +type Dialect interface { + // Name identifies the Dialect. Note that a Dialect may be common to more + // than one DBMS (e.g. "postgres" is common to PostgreSQL and to + // CockroachDB), while we can maintain different Dialects for the same DBMS + // but different versions (e.g. "mysql5" and "mysql8"). + Name() string + + // Ident returns the given string quoted in a way that is suitable to be + // used as an identifier. Database names, schema names, table names, column + // names are all examples of identifiers. + Ident(string) (string, error) + + // ArgPlaceholder returns a safe argument suitable to be used in a SQL + // prepared statement for the argNum-eth argument passed in execution + // (starting at 1). The SQL92 Standard specifies the question mark ('?') + // should be used in all cases, but some implementations differ. + ArgPlaceholder(argNum int) string + + // SelectFor parses and returns the given row-locking clause for a SELECT + // statement. If the clause is invalid it returns an error. Implementations + // of this method should use ParseRowLockingClause. + // Example: + // + // SELECT * + // FROM mytab + // WHERE id = ? + // {{ .SelectFor "Update NoWait" }}; -- will be uppercased + SelectFor(...string) (string, error) +} + +// RowLockingClause represents a row-locking clause in a SELECT statement. +type RowLockingClause string + +// Valid returns whether the given option is valid. +func (o RowLockingClause) Valid() bool { + switch o { + case SelectForShare, SelectForShareNoWait, SelectForShareSkipLocked, + SelectForUpdate, SelectForUpdateNoWait, SelectForUpdateSkipLocked: + return true + } + return false +} + +// ParseRowLockingClause parses a RowLockingClause from the given strings. This +// should be used by implementations of Dialect to parse the input of the +// SelectFor method. +func ParseRowLockingClause(s ...string) (RowLockingClause, error) { + opt := RowLockingClause(strings.ToUpper(strings.Join(s, " "))) + if !opt.Valid() { + return "", ErrInvalidRowLockingClause + } + + return opt, nil +} + +// Row-locking clause options. +const ( + SelectForShare RowLockingClause = "SHARE" + SelectForShareNoWait RowLockingClause = "SHARE NOWAIT" + SelectForShareSkipLocked RowLockingClause = "SHARE SKIP LOCKED" + SelectForUpdate RowLockingClause = "UPDATE" + SelectForUpdateNoWait RowLockingClause = "UPDATE NOWAIT" + SelectForUpdateSkipLocked RowLockingClause = "UPDATE SKIP LOCKED" +) + +type rowLockingClauseMap map[RowLockingClause]RowLockingClause + +func (rlc rowLockingClauseMap) SelectFor(s ...string) (string, error) { + // all implementations should err on invalid input, otherwise we would just + // be hiding the error until we change the dialect + o, err := ParseRowLockingClause(s...) + if err != nil { + return "", err + } + + var ret string + if len(rlc) > 0 { + ret = "FOR " + string(rlc[o]) + } + + return ret, nil +} + +var rowLockingClauseAll = rowLockingClauseMap{ + SelectForShare: SelectForShare, + SelectForShareNoWait: SelectForShareNoWait, + SelectForShareSkipLocked: SelectForShareSkipLocked, + SelectForUpdate: SelectForUpdate, + SelectForUpdateNoWait: SelectForUpdateNoWait, + SelectForUpdateSkipLocked: SelectForUpdateSkipLocked, +} + +// standardIdent provides standard SQL escaping of identifiers. +type standardIdent struct{} + +func (standardIdent) Ident(s string) (string, error) { + if s == "" { + return "", ErrEmptyIdent + } + 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) + }) +) + +type name string + +func (n name) Name() string { + return string(n) +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_mysql.go b/pkg/storage/unified/sql/sqltemplate/dialect_mysql.go new file mode 100644 index 00000000000..705c8c9eca4 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect_mysql.go @@ -0,0 +1,21 @@ +package sqltemplate + +// MySQL is the default implementation of Dialect for the MySQL DMBS, currently +// supporting MySQL-8.x. It relies on having ANSI_QUOTES SQL Mode enabled. For +// more information about ANSI_QUOTES and SQL Modes see: +// +// https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes +var MySQL = mysql{ + rowLockingClauseMap: rowLockingClauseAll, + argPlaceholderFunc: argFmtSQL92, + name: "mysql", +} + +var _ Dialect = MySQL + +type mysql struct { + standardIdent + rowLockingClauseMap + argPlaceholderFunc + name +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_postgresql.go b/pkg/storage/unified/sql/sqltemplate/dialect_postgresql.go new file mode 100644 index 00000000000..39e8603ae0b --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect_postgresql.go @@ -0,0 +1,37 @@ +package sqltemplate + +import ( + "errors" + "strings" +) + +// PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS. +var PostgreSQL = postgresql{ + rowLockingClauseMap: rowLockingClauseAll, + argPlaceholderFunc: argFmtPositional, + name: "postgres", +} + +var _ Dialect = PostgreSQL + +// PostgreSQL-specific errors. +var ( + ErrPostgreSQLUnsupportedIdent = errors.New("identifiers in PostgreSQL cannot contain the character with code zero") +) + +type postgresql struct { + standardIdent + rowLockingClauseMap + argPlaceholderFunc + name +} + +func (p postgresql) Ident(s string) (string, error) { + // See: + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html + if strings.IndexByte(s, 0) != -1 { + return "", ErrPostgreSQLUnsupportedIdent + } + + return p.standardIdent.Ident(s) +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_postgresql_test.go b/pkg/storage/unified/sql/sqltemplate/dialect_postgresql_test.go new file mode 100644 index 00000000000..11a7dcc2f41 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect_postgresql_test.go @@ -0,0 +1,38 @@ +package sqltemplate + +import ( + "errors" + "testing" +) + +func TestPostgreSQL_Ident(t *testing.T) { + t.Parallel() + + testCases := []struct { + input string + output string + err error + }{ + {input: ``, err: ErrEmptyIdent}, + {input: `polite_example`, output: `"polite_example"`}, + {input: `Juan Carlos`, output: `"Juan Carlos"`}, + { + input: `unpolite_` + string([]byte{0}) + `example`, + err: ErrPostgreSQLUnsupportedIdent, + }, + { + input: `exaggerated " ' ` + "`" + ` example`, + output: `"exaggerated "" ' ` + "`" + ` example"`, + }, + } + + for i, tc := range testCases { + gotOutput, gotErr := PostgreSQL.Ident(tc.input) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != tc.output { + t.Fatalf("unexpected error %v in test case %d", gotErr, i) + } + } +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_sqlite.go b/pkg/storage/unified/sql/sqltemplate/dialect_sqlite.go new file mode 100644 index 00000000000..8a41a8f2c4d --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect_sqlite.go @@ -0,0 +1,18 @@ +package sqltemplate + +// SQLite is an implementation of Dialect for the SQLite DMBS. +var SQLite = sqlite{ + argPlaceholderFunc: argFmtSQL92, + name: "sqlite", +} + +var _ Dialect = SQLite + +type sqlite struct { + // See: + // https://www.sqlite.org/lang_keywords.html + standardIdent + rowLockingClauseMap + argPlaceholderFunc + name +} diff --git a/pkg/storage/unified/sql/sqltemplate/dialect_test.go b/pkg/storage/unified/sql/sqltemplate/dialect_test.go new file mode 100644 index 00000000000..987170fd6f3 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/dialect_test.go @@ -0,0 +1,184 @@ +package sqltemplate + +import ( + "errors" + "strings" + "testing" +) + +func TestSelectForOption_Valid(t *testing.T) { + t.Parallel() + + testCases := []struct { + input RowLockingClause + expected bool + }{ + {input: "", expected: false}, + {input: "share", expected: false}, + {input: SelectForShare, expected: true}, + {input: SelectForShareNoWait, expected: true}, + {input: SelectForShareSkipLocked, expected: true}, + {input: SelectForUpdate, expected: true}, + {input: SelectForUpdateNoWait, expected: true}, + {input: SelectForUpdateSkipLocked, expected: true}, + } + + for i, tc := range testCases { + got := tc.input.Valid() + if got != tc.expected { + t.Fatalf("unexpected %v in test case %d", got, i) + } + } +} + +func TestParseRowLockingClause(t *testing.T) { + t.Parallel() + + splitSpace := func(s string) []string { + return strings.Split(s, " ") + } + + testCases := []struct { + input []string + output RowLockingClause + err error + }{ + {err: ErrInvalidRowLockingClause}, + { + input: []string{" " + string(SelectForShare)}, + err: ErrInvalidRowLockingClause, + }, + { + input: splitSpace(string(SelectForShareNoWait)), + output: SelectForShareNoWait, + }, + { + input: splitSpace(strings.ToLower(string(SelectForShareNoWait))), + output: SelectForShareNoWait, + }, + { + input: splitSpace(strings.ToTitle(string(SelectForShareNoWait))), + output: SelectForShareNoWait, + }, + } + + for i, tc := range testCases { + gotOutput, gotErr := ParseRowLockingClause(tc.input...) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != (tc.output) { + t.Fatalf("unexpected output %q in test case %d", gotOutput, i) + } + } +} + +func TestRowLockingClauseMap_SelectFor(t *testing.T) { + t.Parallel() + + splitSpace := func(s string) []string { + return strings.Split(s, " ") + } + + testCases := []struct { + input []string + output RowLockingClause + err error + }{ + {err: ErrInvalidRowLockingClause}, + {input: []string{"invalid"}, err: ErrInvalidRowLockingClause}, + {input: []string{" share"}, err: ErrInvalidRowLockingClause}, + + { + input: splitSpace(string(SelectForShare)), + output: "FOR " + SelectForShare, + }, + } + + var nilRLC rowLockingClauseMap + for i, tc := range testCases { + gotOutput, gotErr := nilRLC.SelectFor(tc.input...) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("[nil] unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != "" { + t.Fatalf("[nil] unexpected output %v in test case %d", gotOutput, i) + } + + gotOutput, gotErr = rowLockingClauseAll.SelectFor(tc.input...) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("[all] unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != string(tc.output) { + t.Fatalf("[all] unexpected output %v in test case %d", gotOutput, i) + } + } +} + +func TestStandardIdent_Ident(t *testing.T) { + t.Parallel() + + testCases := []struct { + input string + output string + err error + }{ + {input: ``, err: ErrEmptyIdent}, + {input: `polite_example`, output: `"polite_example"`}, + {input: `Juan Carlos`, output: `"Juan Carlos"`}, + { + input: `exaggerated " ' ` + "`" + ` example`, + output: `"exaggerated "" ' ` + "`" + ` example"`, + }, + } + + for i, tc := range testCases { + gotOutput, gotErr := standardIdent{}.Ident(tc.input) + if !errors.Is(gotErr, tc.err) { + t.Fatalf("unexpected error %v in test case %d", gotErr, i) + } + if gotOutput != tc.output { + t.Fatalf("unexpected error %v in test case %d", gotErr, i) + } + } +} + +func TestArgPlaceholderFunc(t *testing.T) { + t.Parallel() + + testCases := []struct { + input int + valuePositional string + }{ + { + input: 1, + valuePositional: "$1", + }, + { + input: 16, + valuePositional: "$16", + }, + } + + for i, tc := range testCases { + got := argFmtSQL92(tc.input) + if got != "?" { + t.Fatalf("[argFmtSQL92] unexpected value %q in test case %d", got, i) + } + + got = argFmtPositional(tc.input) + if got != tc.valuePositional { + t.Fatalf("[argFmtPositional] unexpected value %q in test case %d", got, i) + } + } +} + +func TestName_Name(t *testing.T) { + t.Parallel() + + const v = "some dialect name" + n := name(v) + if n.Name() != v { + t.Fatalf("unexpected dialect name %q", n.Name()) + } +} diff --git a/pkg/storage/unified/sql/sqltemplate/example_test.go b/pkg/storage/unified/sql/sqltemplate/example_test.go new file mode 100644 index 00000000000..e8e8241f881 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/example_test.go @@ -0,0 +1,156 @@ +package sqltemplate + +import ( + "fmt" + "regexp" + "strings" + "text/template" +) + +// This file contains runnable examples. They serve the purpose of providing +// idiomatic usage of the package as well as showing how it actually works, +// since the examples are actually run together with regular Go tests. Note that +// the "Output" comment section at the end of each function starting with +// "Example" is used by the standard Go test tool to check that the standard +// output of the function matches the commented text until the end of the +// function. If you change the function, you may need to adapt that comment +// section as it's possible that the output changes, causing it to fail tests. +// To learn more about Go's runnable tests, which are a core builtin feature of +// Go's standard testing library, see: +// https://pkg.go.dev/testing#hdr-Examples +// +// If you're unfamiliar with Go text templating language, please, consider +// reading that library's documentation first. + +// In this example we will use both Args and Dialect to dynamically and securely +// build SQL queries, while also keeping track of the arguments that need to be +// passed to the database methods to replace the placeholder "?" with the +// correct values. + +// We will start by assuming we receive a request to retrieve a user's +// information and that we need to provide a certain response. + +type GetUserRequest struct { + ID int +} + +type GetUserResponse struct { + ID int + Type string + Name string +} + +// Our template will take care for us of taking the request to build the query, +// and then sort the arguments for execution as well as preparing the values +// that need to be read for the response. We wil create a struct to pass the +// request and an empty response, as well as a *SQLTemplate that will provide +// the methods to achieve our purpose:: + +type GetUserQuery struct { + *SQLTemplate + Request *GetUserRequest + Response *GetUserResponse +} + +// And finally we will define our template, that is free to use all the power of +// the Go templating language, plus the methods we added with *SQLTemplate: +var getUserTmpl = template.Must(template.New("example").Parse(` + SELECT + {{ .Ident "id" | .Into .Response.ID }}, + {{ .Ident "type" | .Into .Response.Type }}, + {{ .Ident "name" | .Into .Response.Name }} + + FROM {{ .Ident "users" }} + WHERE + {{ .Ident "id" }} = {{ .Arg .Request.ID }}; +`)) + +// There are three interesting methods used in the above template: +// 1. Ident: safely escape a SQL identifier. Even though here the only +// identifier that may be problematic is "type" (because it is a reserved +// word in many dialects), it is a good practice to escape all identifiers +// just to make sure we're accounting for all variability in dialects, and +// also for consistency. +// 2. Into: this causes the selected field to be saved to the corresponding +// field of GetUserQuery. +// 3. Arg: this allows us to state that at this point will be a "?" that has to +// be populated with the value of the given field of GetUserQuery. + +func Example() { + // Let's pretend this example function is the handler of the GetUser method + // of our service to see how it all works together. + + queryData := &GetUserQuery{ + // The dialect (in this case we chose MySQL) should be set in your + // service at startup when you connect to your database + SQLTemplate: New(MySQL), + + // This is a synthetic request for our test + Request: &GetUserRequest{ + ID: 1, + }, + + // Create an empty response to be populated + Response: new(GetUserResponse), + } + + // The next step is to execute the query template for our queryData, and + // generate the arguments for the db.QueryRow and row.Scan methods later + query, err := Execute(getUserTmpl, queryData) + if err != nil { + panic(err) // terminate the runnable example on error + } + + // Assuming that we have a *sql.DB object named "db", we could now make our + // query with: + // 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.GetArgs()) != 1 { + panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args)) + } + id, ok := queryData.GetArgs()[0].(int) + if !ok || id != queryData.Request.ID { + panic(fmt.Sprintf("unexpected args: %#v", queryData.Args)) + } + + // 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.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.GetScanDest()) != 3 { + panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest)) + } + 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.GetScanDest()[1].(*string) + if !ok || typePtr != &queryData.Response.Type { + panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest)) + } + namePtr, ok := queryData.GetScanDest()[2].(*string) + if !ok || namePtr != &queryData.Response.Name { + panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest)) + } + + // Remember the variable "query"? Well, we didn't check it. We will now make + // use of Go's runnable examples and print its contents to standard output + // so Go's tooling verify this example's output each time we run tests. + // By the way, to make the result more stable, we will remove some + // unnecessary white space from the query. + whiteSpaceRE := regexp.MustCompile(`\s+`) + query = strings.TrimSpace(whiteSpaceRE.ReplaceAllString(query, " ")) + fmt.Println(query) + + // Output: + // SELECT "id", "type", "name" FROM "users" WHERE "id" = ?; +} diff --git a/pkg/storage/unified/sql/sqltemplate/into.go b/pkg/storage/unified/sql/sqltemplate/into.go new file mode 100644 index 00000000000..2896f89481c --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/into.go @@ -0,0 +1,41 @@ +package sqltemplate + +import ( + "fmt" + "reflect" +) + +type ScanDest struct { + values []any + colNames []string +} + +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.values = append(i.values, v.Addr().Interface()) + i.colNames = append(i.colNames, colName) + + return colName, nil +} + +func (i *ScanDest) Reset() { + i.values = nil +} + +func (i *ScanDest) GetScanDest() []any { + return i.values +} + +func (i *ScanDest) GetColNames() []string { + return i.colNames +} + +type ScanDestIface interface { + Into(v reflect.Value, colName string) (string, error) + GetScanDest() []any + GetColNames() []string + Reset() +} diff --git a/pkg/storage/unified/sql/sqltemplate/into_test.go b/pkg/storage/unified/sql/sqltemplate/into_test.go new file mode 100644 index 00000000000..b4b06767f7c --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/into_test.go @@ -0,0 +1,51 @@ +package sqltemplate + +import ( + "reflect" + "slices" + "testing" +) + +func TestScanDest_Into(t *testing.T) { + t.Parallel() + + var d ScanDest + + colName, err := d.Into(reflect.Value{}, "some field") + if colName != "" || err == nil || len(d.GetScanDest()) != 0 { + t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", + colName, err, d) + } + + data := struct { + X int + Y byte + }{} + dataVal := reflect.ValueOf(&data).Elem() + + expectedColNames := []string{"some int", "and a byte"} + + colName, err = d.Into(dataVal.FieldByName("X"), expectedColNames[0]) + v := d.GetScanDest() + if err != nil || colName != expectedColNames[0] || len(v) != 1 || v[0] != &data.X { + t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", + colName, err, d) + } + + colName, err = d.Into(dataVal.FieldByName("Y"), expectedColNames[1]) + v = d.GetScanDest() + if err != nil || colName != expectedColNames[1] || len(v) != 2 || v[1] != &data.Y { + t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v", + colName, err, d) + } + + if gotColNames := d.GetColNames(); !slices.Equal(expectedColNames, gotColNames) { + t.Fatalf("unexpected column names: %v", gotColNames) + } + + d.Reset() + v = d.GetScanDest() + if len(v) != 0 { + t.Fatalf("unexpected values after reset: %v", v) + } +} diff --git a/pkg/storage/unified/sql/sqltemplate/mocks/SQLTemplateIface.go b/pkg/storage/unified/sql/sqltemplate/mocks/SQLTemplateIface.go new file mode 100644 index 00000000000..d41f66f97c3 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/mocks/SQLTemplateIface.go @@ -0,0 +1,664 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + reflect "reflect" + + mock "github.com/stretchr/testify/mock" + + sqltemplate "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" +) + +// SQLTemplateIface is an autogenerated mock type for the SQLTemplateIface type +type SQLTemplateIface struct { + mock.Mock +} + +type SQLTemplateIface_Expecter struct { + mock *mock.Mock +} + +func (_m *SQLTemplateIface) EXPECT() *SQLTemplateIface_Expecter { + return &SQLTemplateIface_Expecter{mock: &_m.Mock} +} + +// Arg provides a mock function with given fields: x +func (_m *SQLTemplateIface) Arg(x interface{}) string { + ret := _m.Called(x) + + if len(ret) == 0 { + panic("no return value specified for Arg") + } + + var r0 string + if rf, ok := ret.Get(0).(func(interface{}) string); ok { + r0 = rf(x) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_Arg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Arg' +type SQLTemplateIface_Arg_Call struct { + *mock.Call +} + +// Arg is a helper method to define mock.On call +// - x interface{} +func (_e *SQLTemplateIface_Expecter) Arg(x interface{}) *SQLTemplateIface_Arg_Call { + return &SQLTemplateIface_Arg_Call{Call: _e.mock.On("Arg", x)} +} + +func (_c *SQLTemplateIface_Arg_Call) Run(run func(x interface{})) *SQLTemplateIface_Arg_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(interface{})) + }) + return _c +} + +func (_c *SQLTemplateIface_Arg_Call) Return(_a0 string) *SQLTemplateIface_Arg_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Arg_Call) RunAndReturn(run func(interface{}) string) *SQLTemplateIface_Arg_Call { + _c.Call.Return(run) + return _c +} + +// ArgList provides a mock function with given fields: slice +func (_m *SQLTemplateIface) ArgList(slice reflect.Value) (string, error) { + ret := _m.Called(slice) + + if len(ret) == 0 { + panic("no return value specified for ArgList") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value) (string, error)); ok { + return rf(slice) + } + if rf, ok := ret.Get(0).(func(reflect.Value) string); ok { + r0 = rf(slice) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value) error); ok { + r1 = rf(slice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_ArgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgList' +type SQLTemplateIface_ArgList_Call struct { + *mock.Call +} + +// ArgList is a helper method to define mock.On call +// - slice reflect.Value +func (_e *SQLTemplateIface_Expecter) ArgList(slice interface{}) *SQLTemplateIface_ArgList_Call { + return &SQLTemplateIface_ArgList_Call{Call: _e.mock.On("ArgList", slice)} +} + +func (_c *SQLTemplateIface_ArgList_Call) Run(run func(slice reflect.Value)) *SQLTemplateIface_ArgList_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value)) + }) + return _c +} + +func (_c *SQLTemplateIface_ArgList_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_ArgList_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_ArgList_Call) RunAndReturn(run func(reflect.Value) (string, error)) *SQLTemplateIface_ArgList_Call { + _c.Call.Return(run) + return _c +} + +// ArgPlaceholder provides a mock function with given fields: argNum +func (_m *SQLTemplateIface) ArgPlaceholder(argNum int) string { + ret := _m.Called(argNum) + + if len(ret) == 0 { + panic("no return value specified for ArgPlaceholder") + } + + var r0 string + if rf, ok := ret.Get(0).(func(int) string); ok { + r0 = rf(argNum) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_ArgPlaceholder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgPlaceholder' +type SQLTemplateIface_ArgPlaceholder_Call struct { + *mock.Call +} + +// ArgPlaceholder is a helper method to define mock.On call +// - argNum int +func (_e *SQLTemplateIface_Expecter) ArgPlaceholder(argNum interface{}) *SQLTemplateIface_ArgPlaceholder_Call { + return &SQLTemplateIface_ArgPlaceholder_Call{Call: _e.mock.On("ArgPlaceholder", argNum)} +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) Run(run func(argNum int)) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) Return(_a0 string) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_ArgPlaceholder_Call) RunAndReturn(run func(int) string) *SQLTemplateIface_ArgPlaceholder_Call { + _c.Call.Return(run) + return _c +} + +// GetArgs provides a mock function with given fields: +func (_m *SQLTemplateIface) GetArgs() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetArgs") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// SQLTemplateIface_GetArgs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetArgs' +type SQLTemplateIface_GetArgs_Call struct { + *mock.Call +} + +// GetArgs is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetArgs() *SQLTemplateIface_GetArgs_Call { + return &SQLTemplateIface_GetArgs_Call{Call: _e.mock.On("GetArgs")} +} + +func (_c *SQLTemplateIface_GetArgs_Call) Run(run func()) *SQLTemplateIface_GetArgs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetArgs_Call) Return(_a0 []interface{}) *SQLTemplateIface_GetArgs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetArgs_Call) RunAndReturn(run func() []interface{}) *SQLTemplateIface_GetArgs_Call { + _c.Call.Return(run) + return _c +} + +// GetColNames provides a mock function with given fields: +func (_m *SQLTemplateIface) GetColNames() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetColNames") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// SQLTemplateIface_GetColNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetColNames' +type SQLTemplateIface_GetColNames_Call struct { + *mock.Call +} + +// GetColNames is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetColNames() *SQLTemplateIface_GetColNames_Call { + return &SQLTemplateIface_GetColNames_Call{Call: _e.mock.On("GetColNames")} +} + +func (_c *SQLTemplateIface_GetColNames_Call) Run(run func()) *SQLTemplateIface_GetColNames_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetColNames_Call) Return(_a0 []string) *SQLTemplateIface_GetColNames_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetColNames_Call) RunAndReturn(run func() []string) *SQLTemplateIface_GetColNames_Call { + _c.Call.Return(run) + return _c +} + +// GetScanDest provides a mock function with given fields: +func (_m *SQLTemplateIface) GetScanDest() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetScanDest") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// SQLTemplateIface_GetScanDest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetScanDest' +type SQLTemplateIface_GetScanDest_Call struct { + *mock.Call +} + +// GetScanDest is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) GetScanDest() *SQLTemplateIface_GetScanDest_Call { + return &SQLTemplateIface_GetScanDest_Call{Call: _e.mock.On("GetScanDest")} +} + +func (_c *SQLTemplateIface_GetScanDest_Call) Run(run func()) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_GetScanDest_Call) Return(_a0 []interface{}) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_GetScanDest_Call) RunAndReturn(run func() []interface{}) *SQLTemplateIface_GetScanDest_Call { + _c.Call.Return(run) + return _c +} + +// Ident provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) Ident(_a0 string) (string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Ident") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_Ident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ident' +type SQLTemplateIface_Ident_Call struct { + *mock.Call +} + +// Ident is a helper method to define mock.On call +// - _a0 string +func (_e *SQLTemplateIface_Expecter) Ident(_a0 interface{}) *SQLTemplateIface_Ident_Call { + return &SQLTemplateIface_Ident_Call{Call: _e.mock.On("Ident", _a0)} +} + +func (_c *SQLTemplateIface_Ident_Call) Run(run func(_a0 string)) *SQLTemplateIface_Ident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *SQLTemplateIface_Ident_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_Ident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_Ident_Call) RunAndReturn(run func(string) (string, error)) *SQLTemplateIface_Ident_Call { + _c.Call.Return(run) + return _c +} + +// Into provides a mock function with given fields: v, colName +func (_m *SQLTemplateIface) Into(v reflect.Value, colName string) (string, error) { + ret := _m.Called(v, colName) + + if len(ret) == 0 { + panic("no return value specified for Into") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value, string) (string, error)); ok { + return rf(v, colName) + } + if rf, ok := ret.Get(0).(func(reflect.Value, string) string); ok { + r0 = rf(v, colName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value, string) error); ok { + r1 = rf(v, colName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_Into_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Into' +type SQLTemplateIface_Into_Call struct { + *mock.Call +} + +// Into is a helper method to define mock.On call +// - v reflect.Value +// - colName string +func (_e *SQLTemplateIface_Expecter) Into(v interface{}, colName interface{}) *SQLTemplateIface_Into_Call { + return &SQLTemplateIface_Into_Call{Call: _e.mock.On("Into", v, colName)} +} + +func (_c *SQLTemplateIface_Into_Call) Run(run func(v reflect.Value, colName string)) *SQLTemplateIface_Into_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value), args[1].(string)) + }) + return _c +} + +func (_c *SQLTemplateIface_Into_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_Into_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_Into_Call) RunAndReturn(run func(reflect.Value, string) (string, error)) *SQLTemplateIface_Into_Call { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with given fields: +func (_m *SQLTemplateIface) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SQLTemplateIface_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type SQLTemplateIface_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Name() *SQLTemplateIface_Name_Call { + return &SQLTemplateIface_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *SQLTemplateIface_Name_Call) Run(run func()) *SQLTemplateIface_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Name_Call) Return(_a0 string) *SQLTemplateIface_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Name_Call) RunAndReturn(run func() string) *SQLTemplateIface_Name_Call { + _c.Call.Return(run) + return _c +} + +// Reset provides a mock function with given fields: +func (_m *SQLTemplateIface) Reset() { + _m.Called() +} + +// SQLTemplateIface_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type SQLTemplateIface_Reset_Call struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Reset() *SQLTemplateIface_Reset_Call { + return &SQLTemplateIface_Reset_Call{Call: _e.mock.On("Reset")} +} + +func (_c *SQLTemplateIface_Reset_Call) Run(run func()) *SQLTemplateIface_Reset_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Reset_Call) Return() *SQLTemplateIface_Reset_Call { + _c.Call.Return() + return _c +} + +func (_c *SQLTemplateIface_Reset_Call) RunAndReturn(run func()) *SQLTemplateIface_Reset_Call { + _c.Call.Return(run) + return _c +} + +// SelectFor provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) SelectFor(_a0 ...string) (string, error) { + _va := make([]interface{}, len(_a0)) + for _i := range _a0 { + _va[_i] = _a0[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SelectFor") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(...string) (string, error)); ok { + return rf(_a0...) + } + if rf, ok := ret.Get(0).(func(...string) string); ok { + r0 = rf(_a0...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(...string) error); ok { + r1 = rf(_a0...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SQLTemplateIface_SelectFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectFor' +type SQLTemplateIface_SelectFor_Call struct { + *mock.Call +} + +// SelectFor is a helper method to define mock.On call +// - _a0 ...string +func (_e *SQLTemplateIface_Expecter) SelectFor(_a0 ...interface{}) *SQLTemplateIface_SelectFor_Call { + return &SQLTemplateIface_SelectFor_Call{Call: _e.mock.On("SelectFor", + append([]interface{}{}, _a0...)...)} +} + +func (_c *SQLTemplateIface_SelectFor_Call) Run(run func(_a0 ...string)) *SQLTemplateIface_SelectFor_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *SQLTemplateIface_SelectFor_Call) Return(_a0 string, _a1 error) *SQLTemplateIface_SelectFor_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SQLTemplateIface_SelectFor_Call) RunAndReturn(run func(...string) (string, error)) *SQLTemplateIface_SelectFor_Call { + _c.Call.Return(run) + return _c +} + +// SetDialect provides a mock function with given fields: _a0 +func (_m *SQLTemplateIface) SetDialect(_a0 sqltemplate.Dialect) { + _m.Called(_a0) +} + +// SQLTemplateIface_SetDialect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDialect' +type SQLTemplateIface_SetDialect_Call struct { + *mock.Call +} + +// SetDialect is a helper method to define mock.On call +// - _a0 sqltemplate.Dialect +func (_e *SQLTemplateIface_Expecter) SetDialect(_a0 interface{}) *SQLTemplateIface_SetDialect_Call { + return &SQLTemplateIface_SetDialect_Call{Call: _e.mock.On("SetDialect", _a0)} +} + +func (_c *SQLTemplateIface_SetDialect_Call) Run(run func(_a0 sqltemplate.Dialect)) *SQLTemplateIface_SetDialect_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(sqltemplate.Dialect)) + }) + return _c +} + +func (_c *SQLTemplateIface_SetDialect_Call) Return() *SQLTemplateIface_SetDialect_Call { + _c.Call.Return() + return _c +} + +func (_c *SQLTemplateIface_SetDialect_Call) RunAndReturn(run func(sqltemplate.Dialect)) *SQLTemplateIface_SetDialect_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: +func (_m *SQLTemplateIface) Validate() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SQLTemplateIface_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type SQLTemplateIface_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *SQLTemplateIface_Expecter) Validate() *SQLTemplateIface_Validate_Call { + return &SQLTemplateIface_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *SQLTemplateIface_Validate_Call) Run(run func()) *SQLTemplateIface_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *SQLTemplateIface_Validate_Call) Return(_a0 error) *SQLTemplateIface_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SQLTemplateIface_Validate_Call) RunAndReturn(run func() error) *SQLTemplateIface_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewSQLTemplateIface creates a new instance of SQLTemplateIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSQLTemplateIface(t interface { + mock.TestingT + Cleanup(func()) +}) *SQLTemplateIface { + mock := &SQLTemplateIface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/storage/unified/sql/sqltemplate/mocks/WithResults.go b/pkg/storage/unified/sql/sqltemplate/mocks/WithResults.go new file mode 100644 index 00000000000..7c98a0c3433 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/mocks/WithResults.go @@ -0,0 +1,719 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + reflect "reflect" + + mock "github.com/stretchr/testify/mock" + + sqltemplate "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" +) + +// WithResults is an autogenerated mock type for the WithResults type +type WithResults[T interface{}] struct { + mock.Mock +} + +type WithResults_Expecter[T interface{}] struct { + mock *mock.Mock +} + +func (_m *WithResults[T]) EXPECT() *WithResults_Expecter[T] { + return &WithResults_Expecter[T]{mock: &_m.Mock} +} + +// Arg provides a mock function with given fields: x +func (_m *WithResults[T]) Arg(x interface{}) string { + ret := _m.Called(x) + + if len(ret) == 0 { + panic("no return value specified for Arg") + } + + var r0 string + if rf, ok := ret.Get(0).(func(interface{}) string); ok { + r0 = rf(x) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_Arg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Arg' +type WithResults_Arg_Call[T interface{}] struct { + *mock.Call +} + +// Arg is a helper method to define mock.On call +// - x interface{} +func (_e *WithResults_Expecter[T]) Arg(x interface{}) *WithResults_Arg_Call[T] { + return &WithResults_Arg_Call[T]{Call: _e.mock.On("Arg", x)} +} + +func (_c *WithResults_Arg_Call[T]) Run(run func(x interface{})) *WithResults_Arg_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(interface{})) + }) + return _c +} + +func (_c *WithResults_Arg_Call[T]) Return(_a0 string) *WithResults_Arg_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Arg_Call[T]) RunAndReturn(run func(interface{}) string) *WithResults_Arg_Call[T] { + _c.Call.Return(run) + return _c +} + +// ArgList provides a mock function with given fields: slice +func (_m *WithResults[T]) ArgList(slice reflect.Value) (string, error) { + ret := _m.Called(slice) + + if len(ret) == 0 { + panic("no return value specified for ArgList") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value) (string, error)); ok { + return rf(slice) + } + if rf, ok := ret.Get(0).(func(reflect.Value) string); ok { + r0 = rf(slice) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value) error); ok { + r1 = rf(slice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_ArgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgList' +type WithResults_ArgList_Call[T interface{}] struct { + *mock.Call +} + +// ArgList is a helper method to define mock.On call +// - slice reflect.Value +func (_e *WithResults_Expecter[T]) ArgList(slice interface{}) *WithResults_ArgList_Call[T] { + return &WithResults_ArgList_Call[T]{Call: _e.mock.On("ArgList", slice)} +} + +func (_c *WithResults_ArgList_Call[T]) Run(run func(slice reflect.Value)) *WithResults_ArgList_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value)) + }) + return _c +} + +func (_c *WithResults_ArgList_Call[T]) Return(_a0 string, _a1 error) *WithResults_ArgList_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_ArgList_Call[T]) RunAndReturn(run func(reflect.Value) (string, error)) *WithResults_ArgList_Call[T] { + _c.Call.Return(run) + return _c +} + +// ArgPlaceholder provides a mock function with given fields: argNum +func (_m *WithResults[T]) ArgPlaceholder(argNum int) string { + ret := _m.Called(argNum) + + if len(ret) == 0 { + panic("no return value specified for ArgPlaceholder") + } + + var r0 string + if rf, ok := ret.Get(0).(func(int) string); ok { + r0 = rf(argNum) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_ArgPlaceholder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ArgPlaceholder' +type WithResults_ArgPlaceholder_Call[T interface{}] struct { + *mock.Call +} + +// ArgPlaceholder is a helper method to define mock.On call +// - argNum int +func (_e *WithResults_Expecter[T]) ArgPlaceholder(argNum interface{}) *WithResults_ArgPlaceholder_Call[T] { + return &WithResults_ArgPlaceholder_Call[T]{Call: _e.mock.On("ArgPlaceholder", argNum)} +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) Run(run func(argNum int)) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) Return(_a0 string) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_ArgPlaceholder_Call[T]) RunAndReturn(run func(int) string) *WithResults_ArgPlaceholder_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetArgs provides a mock function with given fields: +func (_m *WithResults[T]) GetArgs() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetArgs") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// WithResults_GetArgs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetArgs' +type WithResults_GetArgs_Call[T interface{}] struct { + *mock.Call +} + +// GetArgs is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetArgs() *WithResults_GetArgs_Call[T] { + return &WithResults_GetArgs_Call[T]{Call: _e.mock.On("GetArgs")} +} + +func (_c *WithResults_GetArgs_Call[T]) Run(run func()) *WithResults_GetArgs_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetArgs_Call[T]) Return(_a0 []interface{}) *WithResults_GetArgs_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetArgs_Call[T]) RunAndReturn(run func() []interface{}) *WithResults_GetArgs_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetColNames provides a mock function with given fields: +func (_m *WithResults[T]) GetColNames() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetColNames") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// WithResults_GetColNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetColNames' +type WithResults_GetColNames_Call[T interface{}] struct { + *mock.Call +} + +// GetColNames is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetColNames() *WithResults_GetColNames_Call[T] { + return &WithResults_GetColNames_Call[T]{Call: _e.mock.On("GetColNames")} +} + +func (_c *WithResults_GetColNames_Call[T]) Run(run func()) *WithResults_GetColNames_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetColNames_Call[T]) Return(_a0 []string) *WithResults_GetColNames_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetColNames_Call[T]) RunAndReturn(run func() []string) *WithResults_GetColNames_Call[T] { + _c.Call.Return(run) + return _c +} + +// GetScanDest provides a mock function with given fields: +func (_m *WithResults[T]) GetScanDest() []interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetScanDest") + } + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// WithResults_GetScanDest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetScanDest' +type WithResults_GetScanDest_Call[T interface{}] struct { + *mock.Call +} + +// GetScanDest is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) GetScanDest() *WithResults_GetScanDest_Call[T] { + return &WithResults_GetScanDest_Call[T]{Call: _e.mock.On("GetScanDest")} +} + +func (_c *WithResults_GetScanDest_Call[T]) Run(run func()) *WithResults_GetScanDest_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_GetScanDest_Call[T]) Return(_a0 []interface{}) *WithResults_GetScanDest_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_GetScanDest_Call[T]) RunAndReturn(run func() []interface{}) *WithResults_GetScanDest_Call[T] { + _c.Call.Return(run) + return _c +} + +// Ident provides a mock function with given fields: _a0 +func (_m *WithResults[T]) Ident(_a0 string) (string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Ident") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Ident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ident' +type WithResults_Ident_Call[T interface{}] struct { + *mock.Call +} + +// Ident is a helper method to define mock.On call +// - _a0 string +func (_e *WithResults_Expecter[T]) Ident(_a0 interface{}) *WithResults_Ident_Call[T] { + return &WithResults_Ident_Call[T]{Call: _e.mock.On("Ident", _a0)} +} + +func (_c *WithResults_Ident_Call[T]) Run(run func(_a0 string)) *WithResults_Ident_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *WithResults_Ident_Call[T]) Return(_a0 string, _a1 error) *WithResults_Ident_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Ident_Call[T]) RunAndReturn(run func(string) (string, error)) *WithResults_Ident_Call[T] { + _c.Call.Return(run) + return _c +} + +// Into provides a mock function with given fields: v, colName +func (_m *WithResults[T]) Into(v reflect.Value, colName string) (string, error) { + ret := _m.Called(v, colName) + + if len(ret) == 0 { + panic("no return value specified for Into") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(reflect.Value, string) (string, error)); ok { + return rf(v, colName) + } + if rf, ok := ret.Get(0).(func(reflect.Value, string) string); ok { + r0 = rf(v, colName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(reflect.Value, string) error); ok { + r1 = rf(v, colName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Into_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Into' +type WithResults_Into_Call[T interface{}] struct { + *mock.Call +} + +// Into is a helper method to define mock.On call +// - v reflect.Value +// - colName string +func (_e *WithResults_Expecter[T]) Into(v interface{}, colName interface{}) *WithResults_Into_Call[T] { + return &WithResults_Into_Call[T]{Call: _e.mock.On("Into", v, colName)} +} + +func (_c *WithResults_Into_Call[T]) Run(run func(v reflect.Value, colName string)) *WithResults_Into_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(reflect.Value), args[1].(string)) + }) + return _c +} + +func (_c *WithResults_Into_Call[T]) Return(_a0 string, _a1 error) *WithResults_Into_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Into_Call[T]) RunAndReturn(run func(reflect.Value, string) (string, error)) *WithResults_Into_Call[T] { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with given fields: +func (_m *WithResults[T]) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// WithResults_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type WithResults_Name_Call[T interface{}] struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Name() *WithResults_Name_Call[T] { + return &WithResults_Name_Call[T]{Call: _e.mock.On("Name")} +} + +func (_c *WithResults_Name_Call[T]) Run(run func()) *WithResults_Name_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Name_Call[T]) Return(_a0 string) *WithResults_Name_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Name_Call[T]) RunAndReturn(run func() string) *WithResults_Name_Call[T] { + _c.Call.Return(run) + return _c +} + +// Reset provides a mock function with given fields: +func (_m *WithResults[T]) Reset() { + _m.Called() +} + +// WithResults_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type WithResults_Reset_Call[T interface{}] struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Reset() *WithResults_Reset_Call[T] { + return &WithResults_Reset_Call[T]{Call: _e.mock.On("Reset")} +} + +func (_c *WithResults_Reset_Call[T]) Run(run func()) *WithResults_Reset_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Reset_Call[T]) Return() *WithResults_Reset_Call[T] { + _c.Call.Return() + return _c +} + +func (_c *WithResults_Reset_Call[T]) RunAndReturn(run func()) *WithResults_Reset_Call[T] { + _c.Call.Return(run) + return _c +} + +// Results provides a mock function with given fields: +func (_m *WithResults[T]) Results() (T, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Results") + } + + var r0 T + var r1 error + if rf, ok := ret.Get(0).(func() (T, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() T); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(T) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_Results_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Results' +type WithResults_Results_Call[T interface{}] struct { + *mock.Call +} + +// Results is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Results() *WithResults_Results_Call[T] { + return &WithResults_Results_Call[T]{Call: _e.mock.On("Results")} +} + +func (_c *WithResults_Results_Call[T]) Run(run func()) *WithResults_Results_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Results_Call[T]) Return(_a0 T, _a1 error) *WithResults_Results_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_Results_Call[T]) RunAndReturn(run func() (T, error)) *WithResults_Results_Call[T] { + _c.Call.Return(run) + return _c +} + +// SelectFor provides a mock function with given fields: _a0 +func (_m *WithResults[T]) SelectFor(_a0 ...string) (string, error) { + _va := make([]interface{}, len(_a0)) + for _i := range _a0 { + _va[_i] = _a0[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SelectFor") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(...string) (string, error)); ok { + return rf(_a0...) + } + if rf, ok := ret.Get(0).(func(...string) string); ok { + r0 = rf(_a0...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(...string) error); ok { + r1 = rf(_a0...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// WithResults_SelectFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectFor' +type WithResults_SelectFor_Call[T interface{}] struct { + *mock.Call +} + +// SelectFor is a helper method to define mock.On call +// - _a0 ...string +func (_e *WithResults_Expecter[T]) SelectFor(_a0 ...interface{}) *WithResults_SelectFor_Call[T] { + return &WithResults_SelectFor_Call[T]{Call: _e.mock.On("SelectFor", + append([]interface{}{}, _a0...)...)} +} + +func (_c *WithResults_SelectFor_Call[T]) Run(run func(_a0 ...string)) *WithResults_SelectFor_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *WithResults_SelectFor_Call[T]) Return(_a0 string, _a1 error) *WithResults_SelectFor_Call[T] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *WithResults_SelectFor_Call[T]) RunAndReturn(run func(...string) (string, error)) *WithResults_SelectFor_Call[T] { + _c.Call.Return(run) + return _c +} + +// SetDialect provides a mock function with given fields: _a0 +func (_m *WithResults[T]) SetDialect(_a0 sqltemplate.Dialect) { + _m.Called(_a0) +} + +// WithResults_SetDialect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDialect' +type WithResults_SetDialect_Call[T interface{}] struct { + *mock.Call +} + +// SetDialect is a helper method to define mock.On call +// - _a0 sqltemplate.Dialect +func (_e *WithResults_Expecter[T]) SetDialect(_a0 interface{}) *WithResults_SetDialect_Call[T] { + return &WithResults_SetDialect_Call[T]{Call: _e.mock.On("SetDialect", _a0)} +} + +func (_c *WithResults_SetDialect_Call[T]) Run(run func(_a0 sqltemplate.Dialect)) *WithResults_SetDialect_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(sqltemplate.Dialect)) + }) + return _c +} + +func (_c *WithResults_SetDialect_Call[T]) Return() *WithResults_SetDialect_Call[T] { + _c.Call.Return() + return _c +} + +func (_c *WithResults_SetDialect_Call[T]) RunAndReturn(run func(sqltemplate.Dialect)) *WithResults_SetDialect_Call[T] { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: +func (_m *WithResults[T]) Validate() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// WithResults_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type WithResults_Validate_Call[T interface{}] struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *WithResults_Expecter[T]) Validate() *WithResults_Validate_Call[T] { + return &WithResults_Validate_Call[T]{Call: _e.mock.On("Validate")} +} + +func (_c *WithResults_Validate_Call[T]) Run(run func()) *WithResults_Validate_Call[T] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *WithResults_Validate_Call[T]) Return(_a0 error) *WithResults_Validate_Call[T] { + _c.Call.Return(_a0) + return _c +} + +func (_c *WithResults_Validate_Call[T]) RunAndReturn(run func() error) *WithResults_Validate_Call[T] { + _c.Call.Return(run) + return _c +} + +// NewWithResults creates a new instance of WithResults. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewWithResults[T interface{}](t interface { + mock.TestingT + Cleanup(func()) +}) *WithResults[T] { + mock := &WithResults[T]{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/storage/unified/sql/sqltemplate/sqltemplate.go b/pkg/storage/unified/sql/sqltemplate/sqltemplate.go new file mode 100644 index 00000000000..551a23fca62 --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/sqltemplate.go @@ -0,0 +1,141 @@ +package sqltemplate + +import ( + "errors" + "regexp" + "strings" + "text/template" +) + +// Package-level errors. +var ( + ErrValidationNotImplemented = errors.New("validation not implemented") + ErrSQLTemplateNoSerialize = errors.New("SQLTemplate should not be serialized") +) + +// SQLTemplate provides comprehensive support for SQL templating, handling +// dialect traits, execution arguments and scanning arguments. +type SQLTemplate struct { + Dialect + Args + ScanDest +} + +// New returns a nee *SQLTemplate that will use the given dialect. +func New(d Dialect) *SQLTemplate { + ret := new(SQLTemplate) + ret.SetDialect(d) + + return ret +} + +func (t *SQLTemplate) Reset() { + t.Args.Reset() + t.ScanDest.Reset() +} + +func (t *SQLTemplate) SetDialect(d Dialect) { + t.Reset() + t.Dialect = d + t.Args.d = d +} + +func (t *SQLTemplate) Validate() error { + return ErrValidationNotImplemented +} + +func (t *SQLTemplate) MarshalJSON() ([]byte, error) { + return nil, ErrSQLTemplateNoSerialize +} + +func (t *SQLTemplate) UnmarshalJSON([]byte) error { + return ErrSQLTemplateNoSerialize +} + +//go:generate mockery --with-expecter --name SQLTemplateIface + +// SQLTemplateIface can be used as argument in general purpose utilities +// expecting a struct embedding *SQLTemplate. +type SQLTemplateIface interface { + Dialect + ArgsIface + ScanDestIface + // Reset calls the Reset method of ArgsIface and ScanDestIface. + Reset() + // SetDialect allows reusing the template components. It should first call + // Reset. + SetDialect(Dialect) + // Validate should be implemented to validate a request before executing the + // template. + Validate() error +} + +//go:generate mockery --with-expecter --name WithResults + +// WithResults has an additional method suited for structs embedding +// *SQLTemplate and returning a set of rows. +type WithResults[T any] interface { + 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 +// text/template as a string and an error. +func Execute(t *template.Template, data any) (string, error) { + var b strings.Builder + if err := t.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// FormatSQL is an opinionated formatter for SQL template output. It can be used +// to reduce the final code length, for debugging, and testing. It is not a +// propoer and full-fledged SQL parser, so it makes the following assumptions, +// which are also good practices for writing your SQL templates: +// 1. There are no SQL comments. Consider adding your comments as template +// comments instead (i.e. "{{/* this is a template comment */}}"). +// 2. There are no multiline strings, and strings do not contain consecutive +// 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) + } + q = strings.TrimSpace(q) + + 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"}, + + // Add newlines and a bit of visual aid + { + re: regexp.MustCompile(`((UNION|INTERSECT|EXCEPT)( (ALL|DISTINCT))? )?SELECT `), + replacement: "\n${1}SELECT ", + }, + { + re: regexp.MustCompile(` (FROM|WHERE|GROUP BY|HAVING|WINDOW|ORDER BY|LIMIT|OFFSET) `), + replacement: "\n $1 ", + }, +} diff --git a/pkg/storage/unified/sql/sqltemplate/sqltemplate_test.go b/pkg/storage/unified/sql/sqltemplate/sqltemplate_test.go new file mode 100644 index 00000000000..c46fc2d7cca --- /dev/null +++ b/pkg/storage/unified/sql/sqltemplate/sqltemplate_test.go @@ -0,0 +1,91 @@ +package sqltemplate + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + "text/template" +) + +func TestSQLTemplate(t *testing.T) { + t.Parallel() + + field := reflect.ValueOf(new(struct { + X int + })).Elem().FieldByName("X") + + tmpl := New(MySQL) + tmpl.Arg(1) + _, err := tmpl.Into(field, "colname") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tmpl.SetDialect(SQLite) + a := tmpl.GetArgs() + d := tmpl.GetScanDest() + if len(a) != 0 || len(d) != 0 { + t.Fatalf("unexpected values after SetDialect(). Args: %v, ScanDest: %v", + a, d) + } + + b, err := json.Marshal(tmpl) + if b != nil || !errors.Is(err, ErrSQLTemplateNoSerialize) { + t.Fatalf("should fail serialization with ErrSQLTemplateNoSerialize") + } + + err = json.Unmarshal([]byte(`{}`), &tmpl) + if !errors.Is(err, ErrSQLTemplateNoSerialize) { + t.Fatalf("should fail deserialization with ErrSQLTemplateNoSerialize") + } + + err = tmpl.Validate() + if !errors.Is(err, ErrValidationNotImplemented) { + t.Fatalf("should fail with ErrValidationNotImplemented") + } +} + +func TestExecute(t *testing.T) { + t.Parallel() + + tmpl := template.Must(template.New("test").Parse(`{{ .ID }}`)) + + data := struct { + ID int + }{ + ID: 1, + } + + txt, err := Execute(tmpl, data) + if txt != "1" || err != nil { + t.Fatalf("unexpected error, txt: %q, err: %v", txt, err) + } + + txt, err = Execute(tmpl, 1) + if txt != "" || err == nil { + t.Fatalf("unexpected result, txt: %q, err: %v", txt, err) + } +} + +func TestFormatSQL(t *testing.T) { + t.Parallel() + + // TODO: improve testing + + const ( + input = ` + SELECT * + FROM "mytab" AS t + WHERE "id">= 3 AND "str" = ? ; + ` + expected = `SELECT * + FROM "mytab" AS t + WHERE "id" >= 3 AND "str" = ?;` + ) + + got := FormatSQL(input) + if expected != got { + t.Fatalf("Unexpected output.\n\tExpected: %s\n\tActual: %s", expected, + got) + } +} diff --git a/pkg/storage/unified/sql/testdata/entity_folder_insert_1_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_folder_insert_1_mysql_sqlite.sql new file mode 100644 index 00000000000..37a3dcae9ed --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_folder_insert_1_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity_folder" + ("guid", "namespace", "name", "slug_path", "tree", "depth", "lft", "rgt", "detached") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/entity_folder_insert_2_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_folder_insert_2_mysql_sqlite.sql new file mode 100644 index 00000000000..4a8c005bc17 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_folder_insert_2_mysql_sqlite.sql @@ -0,0 +1,5 @@ +INSERT INTO "entity_folder" + ("guid", "namespace", "name", "slug_path", "tree", "depth", "lft", "rgt", "detached") + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/entity_history_read_full_mysql.sql b/pkg/storage/unified/sql/testdata/entity_history_read_full_mysql.sql new file mode 100644 index 00000000000..92856fccc9f --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_history_read_full_mysql.sql @@ -0,0 +1,5 @@ +SELECT e."guid", e."resource_version", e."key", e."group", e."group_version", e."resource", e."namespace", e."name", e."folder", e."meta", e."body", e."status", e."size", e."etag", e."created_at", e."created_by", e."updated_at", e."updated_by", e."origin", e."origin_key", e."origin_ts", e."title", e."slug", e."description", e."message", e."labels", e."fields", e."errors", e."action" + FROM "entity_history" AS e + WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ? AND "resource_version" <= ? + ORDER BY "resource_version" DESC + LIMIT 1 FOR UPDATE NOWAIT; diff --git a/pkg/storage/unified/sql/testdata/entity_labels_delete_1_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_labels_delete_1_mysql_sqlite.sql new file mode 100644 index 00000000000..99e02bd77f1 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_labels_delete_1_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "entity_labels" WHERE 1 = 1 AND "guid" = ? AND "label" NOT IN (?); diff --git a/pkg/storage/unified/sql/testdata/entity_labels_delete_2_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_labels_delete_2_mysql_sqlite.sql new file mode 100644 index 00000000000..59d29a12d98 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_labels_delete_2_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "entity_labels" WHERE 1 = 1 AND "guid" = ? AND "label" NOT IN (?, ?); diff --git a/pkg/storage/unified/sql/testdata/entity_labels_insert_1_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_labels_insert_1_mysql_sqlite.sql new file mode 100644 index 00000000000..7db16f154c4 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_labels_insert_1_mysql_sqlite.sql @@ -0,0 +1,2 @@ +INSERT INTO "entity_labels" ("guid", "label", "value") + VALUES (?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/entity_labels_insert_2_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/entity_labels_insert_2_mysql_sqlite.sql new file mode 100644 index 00000000000..f16e7766651 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/entity_labels_insert_2_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "entity_labels" ("guid", "label", "value") VALUES + (?, ?, ?), + (?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/resource_delete_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/resource_delete_mysql_sqlite.sql new file mode 100644 index 00000000000..0c4e634b02c --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_delete_mysql_sqlite.sql @@ -0,0 +1 @@ +DELETE FROM "resource" WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ?; diff --git a/pkg/storage/unified/sql/testdata/resource_delete_postgres.sql b/pkg/storage/unified/sql/testdata/resource_delete_postgres.sql new file mode 100644 index 00000000000..47f39a82e5d --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_delete_postgres.sql @@ -0,0 +1 @@ +DELETE FROM "resource" WHERE 1 = 1 AND "namespace" = $1 AND "group" = $2 AND "resource" = $3 AND "name" = $4; diff --git a/pkg/storage/unified/sql/testdata/resource_history_insert_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/resource_history_insert_mysql_sqlite.sql new file mode 100644 index 00000000000..d267d8d31bd --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_history_insert_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "resource_history" + ("guid", "group", "resource", "namespace", "name", "value", "action") + VALUES (?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/resource_insert_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/resource_insert_mysql_sqlite.sql new file mode 100644 index 00000000000..e2268ff0d2f --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_insert_mysql_sqlite.sql @@ -0,0 +1,3 @@ +INSERT INTO "resource" + ("guid", "group", "resource", "namespace", "name", "value", "action") + VALUES (?, ?, ?, ?, ?, ?, ?); diff --git a/pkg/storage/unified/sql/testdata/resource_read_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/resource_read_mysql_sqlite.sql new file mode 100644 index 00000000000..1ff90dacebc --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_read_mysql_sqlite.sql @@ -0,0 +1,3 @@ +SELECT "resource_version", "value" + FROM "resource" + WHERE 1 = 1 AND "namespace" = ? AND "group" = ? AND "resource" = ? AND "name" = ?; diff --git a/pkg/storage/unified/sql/testdata/resource_update_mysql_sqlite.sql b/pkg/storage/unified/sql/testdata/resource_update_mysql_sqlite.sql new file mode 100644 index 00000000000..59eaf391fc2 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/resource_update_mysql_sqlite.sql @@ -0,0 +1,2 @@ +UPDATE "resource" SET "guid" = ?, "value" = ?, "action" = ? +WHERE 1 =1 AND "group" = ? AND "resource" = ? AND "namespace" = ? AND "name" = ? ; \ No newline at end of file