diff --git a/.github/workflows/certification.yml b/.github/workflows/certification.yml index ea697f6f9..2ceb16d95 100644 --- a/.github/workflows/certification.yml +++ b/.github/workflows/certification.yml @@ -249,7 +249,7 @@ jobs: set +e gotestsum --jsonfile ${{ env.TEST_OUTPUT_FILE_PREFIX }}_certification.json \ --junitfile ${{ env.TEST_OUTPUT_FILE_PREFIX }}_certification.xml --format standard-quiet -- \ - -coverprofile=cover.out -covermode=set -coverpkg=${{ env.SOURCE_PATH }} + -coverprofile=cover.out -covermode=set -tags=certtests -coverpkg=${{ env.SOURCE_PATH }} status=$? echo "Completed certification tests for ${{ matrix.component }} ... " if test $status -ne 0; then diff --git a/.golangci.yml b/.golangci.yml index 59fbe0490..26ea4cffa 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,8 +13,8 @@ run: tests: true # list of build tags, all linters use it. Default is empty list. - #build-tags: - # - mytag + build-tags: + - certtests # which dirs to skip: they won't be analyzed; # can use regexp here: generated.*, regexp is applied on full path; diff --git a/state/mysql/mysql.go b/state/mysql/mysql.go index ca3bbbc76..8b68d92ac 100644 --- a/state/mysql/mysql.go +++ b/state/mysql/mysql.go @@ -75,8 +75,6 @@ type MySQL struct { // Instance of the database to issue commands to db *sql.DB - features []state.Feature - // Logger used in a functions logger logger.Logger @@ -97,9 +95,8 @@ func newMySQLStateStore(logger logger.Logger, factory iMySQLFactory) *MySQL { // Store the provided logger and return the object. The rest of the // properties will be populated in the Init function return &MySQL{ - features: []state.Feature{state.FeatureETag, state.FeatureTransactional}, - logger: logger, - factory: factory, + logger: logger, + factory: factory, } } @@ -163,7 +160,15 @@ func (m *MySQL) Init(metadata state.Metadata) error { // Features returns the features available in this state store. func (m *MySQL) Features() []state.Feature { - return m.features + return []state.Feature{state.FeatureETag, state.FeatureTransactional} +} + +// Ping the database. +func (m *MySQL) Ping() error { + if m.db == nil { + return sql.ErrConnDone + } + return m.db.Ping() } // Separated out to make this portion of code testable. @@ -176,7 +181,7 @@ func (m *MySQL) finishInit(db *sql.DB) error { return err } - err = m.db.Ping() + err = m.Ping() if err != nil { m.logger.Error(err) return err @@ -258,23 +263,23 @@ func (m *MySQL) ensureStateTable(stateTableName string) error { } func schemaExists(db *sql.DB, schemaName string) (bool, error) { - // Returns 1 or 0 as a string if the table exists or not - exists := "" + // Returns 1 or 0 if the table exists or not + var exists int query := `SELECT EXISTS ( SELECT SCHEMA_NAME FROM information_schema.schemata WHERE SCHEMA_NAME = ? ) AS 'exists'` err := db.QueryRow(query, schemaName).Scan(&exists) - return exists == "1", err + return exists == 1, err } func tableExists(db *sql.DB, tableName string) (bool, error) { - // Returns 1 or 0 as a string if the table exists or not - exists := "" + // Returns 1 or 0 if the table exists or not + var exists int query := `SELECT EXISTS ( SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_NAME = ? ) AS 'exists'` err := db.QueryRow(query, tableName).Scan(&exists) - return exists == "1", err + return exists == 1, err } // Delete removes an entity from the store @@ -612,11 +617,13 @@ func (m *MySQL) BulkGet(req []state.GetRequest) (bool, []state.BulkGetResponse, // Close implements io.Closer. func (m *MySQL) Close() error { - if m.db != nil { - return m.db.Close() + if m.db == nil { + return nil } - return nil + err := m.db.Close() + m.db = nil + return err } // Validates an identifier, such as table or DB name. diff --git a/state/mysql/mysql_certtests.go b/state/mysql/mysql_certtests.go new file mode 100644 index 000000000..7f3f91058 --- /dev/null +++ b/state/mysql/mysql_certtests.go @@ -0,0 +1,37 @@ +//go:build certtests +// +build certtests + +/* +Copyright 2021 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This file is only built for certification tests and it exposes internal properties of the object +package mysql + +import ( + "database/sql" +) + +// GetConnection returns the database connection. +func (m *MySQL) GetConnection() *sql.DB { + return m.db +} + +// SchemaName returns the value of the schemaName property. +func (m *MySQL) SchemaName() string { + return m.schemaName +} + +// TableName returns the value of the tableName property. +func (m *MySQL) TableName() string { + return m.tableName +} diff --git a/tests/certification/state/mysql/README.md b/tests/certification/state/mysql/README.md index 436dfec77..8cd8c19d5 100644 --- a/tests/certification/state/mysql/README.md +++ b/tests/certification/state/mysql/README.md @@ -29,4 +29,15 @@ d. Get and validate eTag, which should not have changed. ## Transactions -Upsert in Multi function, using 3 keys with updating values and TTL for 2 of the keys, down in the order. +1. Upsert in Multi function, using 3 keys with updating values and TTL for 2 of the keys, down in the order. + +## Close component + +1. Ensure the database connection is closed when the component is closed. + +## Metadata options + +1. Without `schemaName`, check that the default one is used +2. Without `tableName`, check that the default one is used +3. Instantiate a component with a customĀ `schemaName` and validate it's used +4. Instantiate a component with a customĀ `tableName` and validate it's used diff --git a/tests/certification/state/mysql/components/docker/default/mariadb.yaml b/tests/certification/state/mysql/components/docker/default/mariadb.yaml index 8d748bbc1..a7874a2b0 100644 --- a/tests/certification/state/mysql/components/docker/default/mariadb.yaml +++ b/tests/certification/state/mysql/components/docker/default/mariadb.yaml @@ -8,5 +8,3 @@ spec: metadata: - name: connectionString value: "dapr:example@tcp(localhost:3307)/" - - name: schemaName - value: dapr_state_store diff --git a/tests/certification/state/mysql/components/docker/default/mysql.yaml b/tests/certification/state/mysql/components/docker/default/mysql.yaml index b780031ba..2a8c55435 100644 --- a/tests/certification/state/mysql/components/docker/default/mysql.yaml +++ b/tests/certification/state/mysql/components/docker/default/mysql.yaml @@ -8,5 +8,3 @@ spec: metadata: - name: connectionString value: "dapr:example@tcp(localhost:3306)/?allowNativePasswords=true" - - name: schemaName - value: dapr_state_store diff --git a/tests/certification/state/mysql/config.yaml b/tests/certification/state/mysql/config.yaml index 6c95e632f..55bdf6790 100644 --- a/tests/certification/state/mysql/config.yaml +++ b/tests/certification/state/mysql/config.yaml @@ -1,6 +1,6 @@ apiVersion: dapr.io/v1alpha1 kind: Configuration metadata: - name: keyvaultconfig + name: testconfig spec: features: diff --git a/tests/certification/state/mysql/mysql_test.go b/tests/certification/state/mysql/mysql_test.go index 3f732ad34..90bd3c54e 100644 --- a/tests/certification/state/mysql/mysql_test.go +++ b/tests/certification/state/mysql/mysql_test.go @@ -14,22 +14,26 @@ limitations under the License. package main import ( + "database/sql" + "errors" "strconv" + "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dapr/components-contrib/metadata" "github.com/dapr/components-contrib/state" - state_mysql "github.com/dapr/components-contrib/state/mysql" + stateMysql "github.com/dapr/components-contrib/state/mysql" "github.com/dapr/components-contrib/tests/certification/embedded" "github.com/dapr/components-contrib/tests/certification/flow" "github.com/dapr/components-contrib/tests/certification/flow/dockercompose" "github.com/dapr/components-contrib/tests/certification/flow/sidecar" - state_loader "github.com/dapr/dapr/pkg/components/state" + stateLoader "github.com/dapr/dapr/pkg/components/state" "github.com/dapr/dapr/pkg/runtime" - dapr_testing "github.com/dapr/dapr/pkg/testing" + daprTesting "github.com/dapr/dapr/pkg/testing" daprClient "github.com/dapr/go-sdk/client" "github.com/dapr/kit/logger" ) @@ -38,19 +42,29 @@ const ( sidecarNamePrefix = "mysql-sidecar-" dockerComposeYAML = "docker-compose.yaml" certificationTestPrefix = "stable-certification-" + + defaultSchemaName = "dapr_state_store" + defaultTableName = "state" + + mysqlConnString = "root:root@tcp(localhost:3306)/?allowNativePasswords=true" + mariadbConnString = "root:root@tcp(localhost:3307)/" ) func TestMySQL(t *testing.T) { log := logger.NewLogger("dapr.components") - ports, err := dapr_testing.GetFreePorts(1) + ports, err := daprTesting.GetFreePorts(1) require.NoError(t, err) currentGrpcPort := ports[0] - stateRegistry := state_loader.NewRegistry() + registeredComponents := [2]*stateMysql.MySQL{} + stateRegistry := stateLoader.NewRegistry() stateRegistry.Logger = log - stateRegistry.RegisterComponent(func(l logger.Logger) state.Store { - return state_mysql.NewMySQLStateStore(log) + n := atomic.Int32{} + stateRegistry.RegisterComponent(func(_ logger.Logger) state.Store { + component := stateMysql.NewMySQLStateStore(log).(*stateMysql.MySQL) + registeredComponents[n.Add(1)-1] = component + return component }, "mysql") basicTest := func(stateStoreName string) func(ctx flow.Context) error { @@ -177,9 +191,11 @@ func TestMySQL(t *testing.T) { require.NoError(t, err) resp1, err := client.GetState(ctx, stateStoreName, "reqKey1", nil) + require.NoError(t, err) assert.Equal(t, "reqVal101", string(resp1.Value)) resp3, err := client.GetState(ctx, stateStoreName, "reqKey3", nil) + require.NoError(t, err) assert.Equal(t, "reqVal103", string(resp3.Value)) return nil } @@ -235,9 +251,90 @@ func TestMySQL(t *testing.T) { } } + // checks that the connection is closed when the component is closed + closeTest := func(idx int) func(ctx flow.Context) error { + return func(ctx flow.Context) (err error) { + component := registeredComponents[idx] + + // Check connection is active + err = component.Ping() + require.NoError(t, err) + + // Close the component + err = component.Close() + require.NoError(t, err) + + // Ensure the connection is closed + err = component.Ping() + require.Error(t, err) + assert.Truef(t, errors.Is(err, sql.ErrConnDone), "expected sql.ErrConnDone but got %v", err) + + return nil + } + } + + // checks that metadata options schemaName and tableName behave correctly + metadataTest := func(connString string, schemaName string, tableName string) func(ctx flow.Context) error { + return func(ctx flow.Context) (err error) { + properties := map[string]string{ + "connectionString": connString, + } + + // Check if schemaName and tableName are set to custom values + if schemaName != "" { + properties["schemaName"] = schemaName + } else { + schemaName = defaultSchemaName + } + if tableName != "" { + properties["tableName"] = tableName + } else { + tableName = defaultTableName + } + + // Init the component + component := stateMysql.NewMySQLStateStore(log).(*stateMysql.MySQL) + component.Init(state.Metadata{ + Base: metadata.Base{ + Properties: properties, + }, + }) + + // Check connection is active + err = component.Ping() + require.NoError(t, err) + + var exists int + conn := component.GetConnection() + require.NotNil(t, conn) + + // Check that the database exists + query := `SELECT EXISTS ( + SELECT SCHEMA_NAME FROM information_schema.schemata WHERE SCHEMA_NAME = ? + ) AS 'exists'` + err = conn.QueryRow(query, schemaName).Scan(&exists) + require.NoError(t, err) + assert.Equal(t, 1, exists) + + // Check that the table exists + query = `SELECT EXISTS ( + SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_NAME = ? + ) AS 'exists'` + err = conn.QueryRow(query, tableName).Scan(&exists) + require.NoError(t, err) + assert.Equal(t, 1, exists) + + // Close the component + err = component.Close() + require.NoError(t, err) + + return nil + } + } + flow.New(t, "Run tests"). Step(dockercompose.Run("db", dockerComposeYAML)). - Step("wait for component to start", flow.Sleep(20*time.Second)). + Step("wait for component to start", flow.Sleep(30*time.Second)). Step(sidecar.Run(sidecarNamePrefix+"dockerDefault", embedded.WithoutApp(), embedded.WithDaprGRPCPort(currentGrpcPort), @@ -264,6 +361,15 @@ func TestMySQL(t *testing.T) { Step("start mariadb", dockercompose.Start("db", dockerComposeYAML, "mariadb")). Step("wait for component to start", flow.Sleep(10*time.Second)). Step("Run connection test on mariadb", testGetAfterDBRestart("mariadb")). + // Test closing the connection + // We don't know exactly which database is which (since init order isn't deterministic), so we'll just close both + Step("Close database connection 1", closeTest(0)). + Step("Close database connection 2", closeTest(1)). + // Metadata + Step("Default schemaName and tableName on mysql", metadataTest(mysqlConnString, "", "")). + Step("Custom schemaName and tableName on mysql", metadataTest(mysqlConnString, "mydaprdb", "mytable")). + Step("Default schemaName and tableName on mariadb", metadataTest(mariadbConnString, "", "")). + Step("Custom schemaName and tableName on mariadb", metadataTest(mariadbConnString, "mydaprdb", "mytable")). // Run tests Run() }