From 276854768e2632b223791ef247e86bdbcb5a5153 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 13 Jan 2026 12:50:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(dbmanager):=20=E2=9C=A8=20add=20support=20?= =?UTF-8?q?for=20existing=20SQL=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced NewConnectionFromDB function to create connections from existing *sql.DB instances. * Added ExistingDBProvider to wrap existing database connections for dbmanager features. * Implemented tests for NewConnectionFromDB and ExistingDBProvider functionalities. --- pkg/dbmanager/factory.go | 16 ++ pkg/dbmanager/factory_test.go | 210 ++++++++++++++++++++ pkg/dbmanager/providers/existing_db.go | 111 +++++++++++ pkg/dbmanager/providers/existing_db_test.go | 194 ++++++++++++++++++ 4 files changed, 531 insertions(+) create mode 100644 pkg/dbmanager/factory_test.go create mode 100644 pkg/dbmanager/providers/existing_db.go create mode 100644 pkg/dbmanager/providers/existing_db_test.go diff --git a/pkg/dbmanager/factory.go b/pkg/dbmanager/factory.go index 9a0efa2..0cbc950 100644 --- a/pkg/dbmanager/factory.go +++ b/pkg/dbmanager/factory.go @@ -1,6 +1,7 @@ package dbmanager import ( + "database/sql" "fmt" "github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers" @@ -49,3 +50,18 @@ func createProvider(dbType DatabaseType) (Provider, error) { // Provider is an alias to the providers.Provider interface // This allows dbmanager package consumers to use Provider without importing providers type Provider = providers.Provider + +// NewConnectionFromDB creates a new Connection from an existing *sql.DB +// This allows you to use dbmanager features (ORM wrappers, health checks, etc.) +// with a database connection that was opened outside of dbmanager +// +// Parameters: +// - name: A unique name for this connection +// - dbType: The database type (DatabaseTypePostgreSQL, DatabaseTypeSQLite, or DatabaseTypeMSSQL) +// - db: An existing *sql.DB connection +// +// Returns a Connection that wraps the existing *sql.DB +func NewConnectionFromDB(name string, dbType DatabaseType, db *sql.DB) Connection { + provider := providers.NewExistingDBProvider(db, name) + return newSQLConnection(name, dbType, ConnectionConfig{Name: name, Type: dbType}, provider) +} diff --git a/pkg/dbmanager/factory_test.go b/pkg/dbmanager/factory_test.go new file mode 100644 index 0000000..38c0312 --- /dev/null +++ b/pkg/dbmanager/factory_test.go @@ -0,0 +1,210 @@ +package dbmanager + +import ( + "context" + "database/sql" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestNewConnectionFromDB(t *testing.T) { + // Open a SQLite in-memory database + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create a connection from the existing database + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + if conn == nil { + t.Fatal("Expected connection to be created") + } + + // Verify connection properties + if conn.Name() != "test-connection" { + t.Errorf("Expected name 'test-connection', got '%s'", conn.Name()) + } + + if conn.Type() != DatabaseTypeSQLite { + t.Errorf("Expected type DatabaseTypeSQLite, got '%s'", conn.Type()) + } +} + +func TestNewConnectionFromDB_Connect(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + // Connect should verify the existing connection works + err = conn.Connect(ctx) + if err != nil { + t.Errorf("Expected Connect to succeed, got error: %v", err) + } + + // Cleanup + conn.Close() +} + +func TestNewConnectionFromDB_Native(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + err = conn.Connect(ctx) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + // Get native DB + nativeDB, err := conn.Native() + if err != nil { + t.Errorf("Expected Native to succeed, got error: %v", err) + } + + if nativeDB != db { + t.Error("Expected Native to return the same database instance") + } +} + +func TestNewConnectionFromDB_Bun(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + err = conn.Connect(ctx) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + // Get Bun ORM + bunDB, err := conn.Bun() + if err != nil { + t.Errorf("Expected Bun to succeed, got error: %v", err) + } + + if bunDB == nil { + t.Error("Expected Bun to return a non-nil instance") + } +} + +func TestNewConnectionFromDB_GORM(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + err = conn.Connect(ctx) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + // Get GORM + gormDB, err := conn.GORM() + if err != nil { + t.Errorf("Expected GORM to succeed, got error: %v", err) + } + + if gormDB == nil { + t.Error("Expected GORM to return a non-nil instance") + } +} + +func TestNewConnectionFromDB_HealthCheck(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + err = conn.Connect(ctx) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + // Health check should succeed + err = conn.HealthCheck(ctx) + if err != nil { + t.Errorf("Expected HealthCheck to succeed, got error: %v", err) + } +} + +func TestNewConnectionFromDB_Stats(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-connection", DatabaseTypeSQLite, db) + ctx := context.Background() + + err = conn.Connect(ctx) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + stats := conn.Stats() + if stats == nil { + t.Fatal("Expected stats to be returned") + } + + if stats.Name != "test-connection" { + t.Errorf("Expected stats.Name to be 'test-connection', got '%s'", stats.Name) + } + + if stats.Type != DatabaseTypeSQLite { + t.Errorf("Expected stats.Type to be DatabaseTypeSQLite, got '%s'", stats.Type) + } + + if !stats.Connected { + t.Error("Expected stats.Connected to be true") + } +} + +func TestNewConnectionFromDB_PostgreSQL(t *testing.T) { + // This test just verifies the factory works with PostgreSQL type + // It won't actually connect since we're using SQLite + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + conn := NewConnectionFromDB("test-pg", DatabaseTypePostgreSQL, db) + if conn == nil { + t.Fatal("Expected connection to be created") + } + + if conn.Type() != DatabaseTypePostgreSQL { + t.Errorf("Expected type DatabaseTypePostgreSQL, got '%s'", conn.Type()) + } +} diff --git a/pkg/dbmanager/providers/existing_db.go b/pkg/dbmanager/providers/existing_db.go new file mode 100644 index 0000000..9b56a75 --- /dev/null +++ b/pkg/dbmanager/providers/existing_db.go @@ -0,0 +1,111 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "sync" + + "go.mongodb.org/mongo-driver/mongo" +) + +// ExistingDBProvider wraps an existing *sql.DB connection +// This allows using dbmanager features with a database connection +// that was opened outside of the dbmanager package +type ExistingDBProvider struct { + db *sql.DB + name string + mu sync.RWMutex +} + +// NewExistingDBProvider creates a new provider wrapping an existing *sql.DB +func NewExistingDBProvider(db *sql.DB, name string) *ExistingDBProvider { + return &ExistingDBProvider{ + db: db, + name: name, + } +} + +// Connect verifies the existing database connection is valid +// It does NOT create a new connection, but ensures the existing one works +func (p *ExistingDBProvider) Connect(ctx context.Context, cfg ConnectionConfig) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.db == nil { + return fmt.Errorf("database connection is nil") + } + + // Verify the connection works + if err := p.db.PingContext(ctx); err != nil { + return fmt.Errorf("failed to ping existing database: %w", err) + } + + return nil +} + +// Close closes the underlying database connection +func (p *ExistingDBProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.db == nil { + return nil + } + + return p.db.Close() +} + +// HealthCheck verifies the connection is alive +func (p *ExistingDBProvider) HealthCheck(ctx context.Context) error { + p.mu.RLock() + defer p.mu.RUnlock() + + if p.db == nil { + return fmt.Errorf("database connection is nil") + } + + return p.db.PingContext(ctx) +} + +// GetNative returns the wrapped *sql.DB +func (p *ExistingDBProvider) GetNative() (*sql.DB, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + if p.db == nil { + return nil, fmt.Errorf("database connection is nil") + } + + return p.db, nil +} + +// GetMongo returns an error since this is a SQL database +func (p *ExistingDBProvider) GetMongo() (*mongo.Client, error) { + return nil, ErrNotMongoDB +} + +// Stats returns connection statistics +func (p *ExistingDBProvider) Stats() *ConnectionStats { + p.mu.RLock() + defer p.mu.RUnlock() + + stats := &ConnectionStats{ + Name: p.name, + Type: "sql", // Generic since we don't know the specific type + Connected: p.db != nil, + } + + if p.db != nil { + dbStats := p.db.Stats() + stats.OpenConnections = dbStats.OpenConnections + stats.InUse = dbStats.InUse + stats.Idle = dbStats.Idle + stats.WaitCount = dbStats.WaitCount + stats.WaitDuration = dbStats.WaitDuration + stats.MaxIdleClosed = dbStats.MaxIdleClosed + stats.MaxLifetimeClosed = dbStats.MaxLifetimeClosed + } + + return stats +} diff --git a/pkg/dbmanager/providers/existing_db_test.go b/pkg/dbmanager/providers/existing_db_test.go new file mode 100644 index 0000000..d00e998 --- /dev/null +++ b/pkg/dbmanager/providers/existing_db_test.go @@ -0,0 +1,194 @@ +package providers + +import ( + "context" + "database/sql" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func TestNewExistingDBProvider(t *testing.T) { + // Open a SQLite in-memory database + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create provider + provider := NewExistingDBProvider(db, "test-db") + if provider == nil { + t.Fatal("Expected provider to be created") + } + + if provider.name != "test-db" { + t.Errorf("Expected name 'test-db', got '%s'", provider.name) + } +} + +func TestExistingDBProvider_Connect(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + provider := NewExistingDBProvider(db, "test-db") + ctx := context.Background() + + // Connect should verify the connection works + err = provider.Connect(ctx, nil) + if err != nil { + t.Errorf("Expected Connect to succeed, got error: %v", err) + } +} + +func TestExistingDBProvider_Connect_NilDB(t *testing.T) { + provider := NewExistingDBProvider(nil, "test-db") + ctx := context.Background() + + err := provider.Connect(ctx, nil) + if err == nil { + t.Error("Expected Connect to fail with nil database") + } +} + +func TestExistingDBProvider_GetNative(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + provider := NewExistingDBProvider(db, "test-db") + + nativeDB, err := provider.GetNative() + if err != nil { + t.Errorf("Expected GetNative to succeed, got error: %v", err) + } + + if nativeDB != db { + t.Error("Expected GetNative to return the same database instance") + } +} + +func TestExistingDBProvider_GetNative_NilDB(t *testing.T) { + provider := NewExistingDBProvider(nil, "test-db") + + _, err := provider.GetNative() + if err == nil { + t.Error("Expected GetNative to fail with nil database") + } +} + +func TestExistingDBProvider_HealthCheck(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + provider := NewExistingDBProvider(db, "test-db") + ctx := context.Background() + + err = provider.HealthCheck(ctx) + if err != nil { + t.Errorf("Expected HealthCheck to succeed, got error: %v", err) + } +} + +func TestExistingDBProvider_HealthCheck_ClosedDB(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + + provider := NewExistingDBProvider(db, "test-db") + + // Close the database + db.Close() + + ctx := context.Background() + err = provider.HealthCheck(ctx) + if err == nil { + t.Error("Expected HealthCheck to fail with closed database") + } +} + +func TestExistingDBProvider_GetMongo(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + provider := NewExistingDBProvider(db, "test-db") + + _, err = provider.GetMongo() + if err != ErrNotMongoDB { + t.Errorf("Expected ErrNotMongoDB, got: %v", err) + } +} + +func TestExistingDBProvider_Stats(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Set some connection pool settings to test stats + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Hour) + + provider := NewExistingDBProvider(db, "test-db") + + stats := provider.Stats() + if stats == nil { + t.Fatal("Expected stats to be returned") + } + + if stats.Name != "test-db" { + t.Errorf("Expected stats.Name to be 'test-db', got '%s'", stats.Name) + } + + if stats.Type != "sql" { + t.Errorf("Expected stats.Type to be 'sql', got '%s'", stats.Type) + } + + if !stats.Connected { + t.Error("Expected stats.Connected to be true") + } +} + +func TestExistingDBProvider_Close(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + + provider := NewExistingDBProvider(db, "test-db") + + err = provider.Close() + if err != nil { + t.Errorf("Expected Close to succeed, got error: %v", err) + } + + // Verify the database is closed + err = db.Ping() + if err == nil { + t.Error("Expected database to be closed") + } +} + +func TestExistingDBProvider_Close_NilDB(t *testing.T) { + provider := NewExistingDBProvider(nil, "test-db") + + err := provider.Close() + if err != nil { + t.Errorf("Expected Close to succeed with nil database, got error: %v", err) + } +}