diff --git a/go.work.sum b/go.work.sum index da01c42da..c36d3c7d6 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1657,4 +1657,4 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8 rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= \ No newline at end of file diff --git a/pkg/gofr/migration/datasource.go b/pkg/gofr/migration/datasource.go index ff5717ea8..34e21bd0c 100644 --- a/pkg/gofr/migration/datasource.go +++ b/pkg/gofr/migration/datasource.go @@ -11,6 +11,7 @@ type Datasource struct { Redis Redis PubSub PubSub Clickhouse Clickhouse + Oracle Oracle Cassandra Cassandra Mongo Mongo ArangoDB ArangoDB diff --git a/pkg/gofr/migration/interface.go b/pkg/gofr/migration/interface.go index 20c370e92..1c5935c7c 100644 --- a/pkg/gofr/migration/interface.go +++ b/pkg/gofr/migration/interface.go @@ -39,6 +39,11 @@ type Clickhouse interface { HealthCheck(ctx context.Context) (any, error) } +type Oracle interface { + Select(ctx context.Context, dest any, query string, args ...any) error + Exec(ctx context.Context, query string, args ...any) error +} + type Cassandra interface { Exec(query string, args ...any) error NewBatch(name string, batchType int) error diff --git a/pkg/gofr/migration/migration.go b/pkg/gofr/migration/migration.go index 21e62e538..6a48ce395 100644 --- a/pkg/gofr/migration/migration.go +++ b/pkg/gofr/migration/migration.go @@ -157,6 +157,13 @@ func initializeDatasources(c *container.Container, ds *Datasource, mg migrator) apply: func(m migrator) migrator { return clickHouseDS{ds.Clickhouse}.apply(m) }, logIdentifier: "Clickhouse", }, + { + condition: func() bool { return !isNil(c.Oracle) }, + setDS: func() { ds.Oracle = c.Oracle }, + apply: func(m migrator) migrator { return oracleDS{c.Oracle}.apply(m) }, + logIdentifier: "Oracle", + }, + { condition: func() bool { return c.PubSub != nil }, setDS: func() { ds.PubSub = c.PubSub }, @@ -223,9 +230,9 @@ func initializeDatasources(c *container.Container, ds *Datasource, mg migrator) } func isNil(i any) bool { - // Get the value of the interface + // Get the value of the interface. val := reflect.ValueOf(i) - // If the interface is not assigned or is nil, return true + // If the interface is not assigned or is nil, return true. return !val.IsValid() || val.IsNil() } diff --git a/pkg/gofr/migration/migration_test.go b/pkg/gofr/migration/migration_test.go index 04342a55b..8f60b41f4 100644 --- a/pkg/gofr/migration/migration_test.go +++ b/pkg/gofr/migration/migration_test.go @@ -202,6 +202,7 @@ func initializeClickHouseRunMocks(t *testing.T) (*MockClickhouse, *container.Con mockContainer.Elasticsearch = nil mockContainer.OpenTSDB = nil mockContainer.ScyllaDB = nil + mockContainer.Oracle = nil mockContainer.Logger = logging.NewMockLogger(logging.DEBUG) mockContainer.Clickhouse = mockClickHouse diff --git a/pkg/gofr/migration/mock_interface.go b/pkg/gofr/migration/mock_interface.go index 975be65b1..b7ce885d7 100644 --- a/pkg/gofr/migration/mock_interface.go +++ b/pkg/gofr/migration/mock_interface.go @@ -396,6 +396,68 @@ func (mr *MockClickhouseMockRecorder) Select(ctx, dest, query any, args ...any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockClickhouse)(nil).Select), varargs...) } +// MockOracle is a mock of Oracle interface. +type MockOracle struct { + ctrl *gomock.Controller + recorder *MockOracleMockRecorder + isgomock struct{} +} + +// MockOracleMockRecorder is the mock recorder for MockOracle. +type MockOracleMockRecorder struct { + mock *MockOracle +} + +// NewMockOracle creates a new mock instance. +func NewMockOracle(ctrl *gomock.Controller) *MockOracle { + mock := &MockOracle{ctrl: ctrl} + mock.recorder = &MockOracleMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOracle) EXPECT() *MockOracleMockRecorder { + return m.recorder +} + +// Exec mocks base method. +func (m *MockOracle) Exec(ctx context.Context, query string, args ...any) error { + m.ctrl.T.Helper() + varargs := []any{ctx, query} + for _, a := range args { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Exec", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Exec indicates an expected call of Exec. +func (mr *MockOracleMockRecorder) Exec(ctx, query any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, query}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockOracle)(nil).Exec), varargs...) +} + +// Select mocks base method. +func (m *MockOracle) Select(ctx context.Context, dest any, query string, args ...any) error { + m.ctrl.T.Helper() + varargs := []any{ctx, dest, query} + for _, a := range args { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Select", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Select indicates an expected call of Select. +func (mr *MockOracleMockRecorder) Select(ctx, dest, query any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, dest, query}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockOracle)(nil).Select), varargs...) +} + // MockCassandra is a mock of Cassandra interface. type MockCassandra struct { ctrl *gomock.Controller @@ -1540,6 +1602,7 @@ type DataPoint struct { Timestamp int64 `json:"timestamp"` Tags map[string]string `json:"tags"` } + // Mockmigrator is a mock of migrator interface. type Mockmigrator struct { ctrl *gomock.Controller diff --git a/pkg/gofr/migration/oracle.go b/pkg/gofr/migration/oracle.go new file mode 100644 index 000000000..75055e203 --- /dev/null +++ b/pkg/gofr/migration/oracle.go @@ -0,0 +1,110 @@ +package migration + +import ( + "context" + "time" + + "gofr.dev/pkg/gofr/container" +) + +type oracleDS struct { + Oracle +} + +type oracleMigrator struct { + Oracle + migrator +} + +// Provides a wrapper to apply the oracle migrator logic. +func (od oracleDS) apply(m migrator) migrator { + return oracleMigrator{ + Oracle: od.Oracle, + migrator: m, + } +} + +const ( + checkAndCreateOracleMigrationTable = ` +BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE gofr_migrations ( + version NUMBER NOT NULL, + method VARCHAR2(64) NOT NULL, + start_time TIMESTAMP NOT NULL, + duration NUMBER NULL, + PRIMARY KEY (version, method) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN RAISE; END IF; +END; +` + getLastOracleGoFrMigration = ` +SELECT NVL(MAX(version), 0) AS last_migration +FROM gofr_migrations +` + insertOracleGoFrMigrationRow = ` +INSERT INTO gofr_migrations (version, method, start_time, duration) +VALUES (:1, :2, :3, :4) +` +) + +// Create migration table if it doesn't exist. +func (om oracleMigrator) checkAndCreateMigrationTable(_ *container.Container) error { + return om.Oracle.Exec(context.Background(), checkAndCreateOracleMigrationTable) +} + +// Get the last applied migration version. +func (om oracleMigrator) getLastMigration(c *container.Container) int64 { + type LastMigration struct { + LastMigration int64 `db:"last_migration"` + } + + var lastMigrations []LastMigration + + var lastMigration int64 + + err := om.Oracle.Select(context.Background(), &lastMigrations, getLastOracleGoFrMigration) + if err != nil { + return 0 + } + + if len(lastMigrations) != 0 { + lastMigration = lastMigrations[0].LastMigration + } + + lm2 := om.migrator.getLastMigration(c) + + if lm2 > lastMigration { + return lm2 + } + + return lastMigration +} + +// Begin a new migration transaction. +func (om oracleMigrator) beginTransaction(c *container.Container) transactionData { + td := om.migrator.beginTransaction(c) + c.Debug("OracleDB Migrator begin successfully") + + return td +} + +// Commit the migration. +func (om oracleMigrator) commitMigration(c *container.Container, data transactionData) error { + err := om.Oracle.Exec(context.Background(), insertOracleGoFrMigrationRow, data.MigrationNumber, + "UP", data.StartTime, time.Since(data.StartTime).Milliseconds()) + if err != nil { + return err + } + + c.Debugf("inserted record for migration %v in oracle gofr_migrations table", data.MigrationNumber) + + return om.migrator.commitMigration(c, data) +} + +// Rollback the migration. +func (om oracleMigrator) rollback(c *container.Container, data transactionData) { + om.migrator.rollback(c, data) + c.Fatalf("migration %v failed and rolled back", data.MigrationNumber) +} diff --git a/pkg/gofr/migration/oracle_test.go b/pkg/gofr/migration/oracle_test.go new file mode 100644 index 000000000..43b9be0ee --- /dev/null +++ b/pkg/gofr/migration/oracle_test.go @@ -0,0 +1,251 @@ +package migration + +import ( + "bytes" + "database/sql" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "gofr.dev/pkg/gofr/container" + "gofr.dev/pkg/gofr/logging" +) + +func oracleSetup(t *testing.T) (migrator, *container.MockOracleDB, *container.Container) { + t.Helper() + + mockContainer, mocks := container.NewMockContainer(t) + + mockOracle := mocks.Oracle + + ds := Datasource{Oracle: mockOracle} + + oracleDB := oracleDS{Oracle: mockOracle} + migrationWithOracle := oracleDB.apply(&ds) + + mockContainer.Oracle = mockOracle + + return migrationWithOracle, mockOracle, mockContainer +} + +func Test_OracleCheckAndCreateMigrationTable(t *testing.T) { + mg, mockOracle, mockContainer := oracleSetup(t) + + testCases := []struct { + desc string + err error + }{ + {"no error", nil}, + {"connection failed", sql.ErrConnDone}, + } + + for i, tc := range testCases { + mockOracle.EXPECT().Exec(gomock.Any(), checkAndCreateOracleMigrationTable).Return(tc.err) + err := mg.checkAndCreateMigrationTable(mockContainer) + assert.Equal(t, tc.err, err, "TEST[%d]: %s failed", i, tc.desc) + } +} + +func Test_OracleGetLastMigration(t *testing.T) { + mg, mockOracle, mockContainer := oracleSetup(t) + + testCases := []struct { + desc string + err error + resp int64 + }{ + {"no error", nil, 0}, + {"connection failed", sql.ErrConnDone, 0}, + } + + for i, tc := range testCases { + mockOracle.EXPECT().Select(gomock.Any(), gomock.Any(), getLastOracleGoFrMigration).Return(tc.err) + + resp := mg.getLastMigration(mockContainer) + assert.Equal(t, tc.resp, resp, "TEST[%d]: %s failed", i, tc.desc) + } +} + +func Test_OracleCommitMigration(t *testing.T) { + mg, mockOracle, mockContainer := oracleSetup(t) + timeNow := time.Now() + td := transactionData{ + StartTime: timeNow, + MigrationNumber: 10, + } + testCases := []struct { + desc string + err error + }{ + {"no error", nil}, + {"connection failed", sql.ErrConnDone}, + } + + for i, tc := range testCases { + mockOracle.EXPECT().Exec(gomock.Any(), insertOracleGoFrMigrationRow, + td.MigrationNumber, "UP", td.StartTime, gomock.Any()).Return(tc.err) + + err := mg.commitMigration(mockContainer, td) + assert.Equal(t, tc.err, err, "TEST[%d]: %s failed", i, tc.desc) + } +} + +func Test_OracleBeginTransaction(t *testing.T) { + logs := captureStdout(func() { + mg, _, mockContainer := oracleSetup(t) + mg.beginTransaction(mockContainer) + }) + assert.Contains(t, logs, "OracleDB Migrator begin successfully") +} + +// captureStdout helper to capture stdout during function execution. +func captureStdout(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + outC := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + outC <- buf.String() + }() + + f() + + _ = w.Close() + os.Stdout = old + out := <-outC + + return out +} + +func TestOracleMigration_RunMigrationSuccess(t *testing.T) { + mockOracle, mockContainer := initializeOracleRunMocks(t) + + ds := Datasource{Oracle: mockOracle} + od := oracleDS{Oracle: mockOracle} + _ = od.apply(&ds) // apply migrator wrapper. + + migrationMap := map[int64]Migrate{ + 1: {UP: func(d Datasource) error { + return d.Oracle.Exec(t.Context(), "CREATE TABLE test (id INT)") + }}, + } + + mockOracle.EXPECT().Exec(gomock.Any(), checkAndCreateOracleMigrationTable).Return(nil) + mockOracle.EXPECT().Select(gomock.Any(), gomock.Any(), getLastOracleGoFrMigration).Return(nil) + mockOracle.EXPECT().Exec(gomock.Any(), "CREATE TABLE test (id INT)").Return(nil) + mockOracle.EXPECT().Exec(gomock.Any(), insertOracleGoFrMigrationRow, int64(1), "UP", gomock.Any(), gomock.Any()).Return(nil) + + Run(migrationMap, mockContainer) +} + +func TestOracleMigration_FailCreateMigrationTable(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockContainer, mocks := container.NewMockContainer(t) + mockOracle := mocks.Oracle + mockContainer.Oracle = mockOracle + + ds := Datasource{Oracle: mockOracle} + od := oracleDS{Oracle: mockOracle} + mg := od.apply(&ds) + + mockOracle.EXPECT().Exec(gomock.Any(), checkAndCreateOracleMigrationTable).Return(sql.ErrConnDone) + + err := mg.checkAndCreateMigrationTable(mockContainer) + assert.Equal(t, sql.ErrConnDone, err) +} + +func TestOracleMigration_GetLastMigration_ReturnsZeroOnError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockContainer, mocks := container.NewMockContainer(t) + mockOracle := mocks.Oracle + mockContainer.Oracle = mockOracle + + ds := Datasource{Oracle: mockOracle} + od := oracleDS{Oracle: mockOracle} + mg := od.apply(&ds) + + mockOracle.EXPECT().Select(gomock.Any(), gomock.Any(), getLastOracleGoFrMigration).Return(sql.ErrConnDone) + + lastMigration := mg.getLastMigration(mockContainer) + assert.Equal(t, int64(0), lastMigration) +} + +func TestOracleMigration_CommitMigration(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockContainer, mocks := container.NewMockContainer(t) + mockOracle := mocks.Oracle + mockContainer.Oracle = mockOracle + + ds := Datasource{Oracle: mockOracle} + od := oracleDS{Oracle: mockOracle} + mg := od.apply(&ds) + + td := transactionData{ + StartTime: time.Now(), + MigrationNumber: 42, + } + + mockOracle.EXPECT(). + Exec(gomock.Any(), insertOracleGoFrMigrationRow, + td.MigrationNumber, "UP", td.StartTime, gomock.Any()). + Return(nil) + + err := mg.commitMigration(mockContainer, td) + assert.NoError(t, err) +} + +func TestOracleMigration_BeginTransaction_Logs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockContainer, mocks := container.NewMockContainer(t) + mockOracle := mocks.Oracle + mockContainer.Logger = logging.NewLogger(logging.DEBUG) + mockContainer.Oracle = mockOracle + + ds := Datasource{Oracle: mockOracle} + od := oracleDS{Oracle: mockOracle} + mg := od.apply(&ds) + + // Capture logs or just call method and rely on it not panicking. + mg.beginTransaction(mockContainer) +} + +func initializeOracleRunMocks(t *testing.T) (*container.MockOracleDB, *container.Container) { + t.Helper() + + mockContainer, mocks := container.NewMockContainer(t) + mockOracle := mocks.Oracle + + // Disable all other datasources by setting to nil. + mockContainer.SQL = nil + mockContainer.Redis = nil + mockContainer.Mongo = nil + mockContainer.Cassandra = nil + mockContainer.PubSub = nil + mockContainer.ArangoDB = nil + mockContainer.SurrealDB = nil + mockContainer.DGraph = nil + mockContainer.Elasticsearch = nil + mockContainer.OpenTSDB = nil + mockContainer.ScyllaDB = nil + mockContainer.Clickhouse = nil + + // Initialize Oracle mock and Logger. + mockContainer.Oracle = mockOracle + mockContainer.Logger = logging.NewMockLogger(logging.DEBUG) + + return mockOracle, mockContainer +}