diff --git a/README.md b/README.md index 50624b7..b519261 100644 --- a/README.md +++ b/README.md @@ -96,18 +96,18 @@ If you prefer an interactive environment like the `mysql` or `psql` CLI where yo sql-doctor shell # Or jump straight into a specific connection / database: -sql-doctor shell -c local-mysql -d rolerift +sql-doctor shell -c local-mysql -d ``` Inside the shell, your prompt reflects your active engine and database: ```text -sql-doctor [mysql@rolerift]> tables -sql-doctor [mysql@rolerift]> select * from users; -sql-doctor [mysql@rolerift]> select * from users\G # Vertical format (one column per line) -sql-doctor [mysql@rolerift]> use shop_db # Switch database on the fly -sql-doctor [mysql@shop_db]> analyze SELECT * FROM orders WHERE status = 'pending' -sql-doctor [mysql@shop_db]> help # View categorized commands -sql-doctor [mysql@shop_db]> exit # Clean exit (discards in-memory session) +sql-doctor [mysql@]> tables +sql-doctor [mysql@]> select * from users; +sql-doctor [mysql@]> select * from users\G # Vertical format (one column per line) +sql-doctor [mysql@]> use # Switch database on the fly +sql-doctor [mysql@]> analyze SELECT * FROM orders WHERE status = 'pending' +sql-doctor [mysql@]> help # View categorized commands +sql-doctor [mysql@]> exit # Clean exit (discards in-memory session) ``` All session state (active connection and selected database) lives in memory during your shell session and is cleanly discarded upon exit. @@ -130,7 +130,7 @@ sql-doctor connect --type postgres --host localhost --port 5432 --user postgres sql-doctor connect --type sqlite --file ./my-app.db --name my-local-db --save # Or run directly against a database URL without saving: -sql-doctor --db-url "postgres://user:pass@localhost:5432/shop_db" db tables +sql-doctor --db-url "postgres://user:pass@localhost:5432/" db tables ``` > **Tip:** You don't need `--save` just to try a connection. Running `connect` without `--save` starts an ephemeral session so you can immediately run subsequent commands in that terminal. @@ -141,7 +141,7 @@ sql-doctor --db-url "postgres://user:pass@localhost:5432/shop_db" db tables sql-doctor databases # Switch the active database -sql-doctor use rolerift +sql-doctor use # Clear session memory sql-doctor disconnect diff --git a/internal/ai/gemini/client.go b/internal/ai/gemini/client.go index 9371a20..7c406c2 100644 --- a/internal/ai/gemini/client.go +++ b/internal/ai/gemini/client.go @@ -1,27 +1,85 @@ package gemini import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "net/http" "strings" + "time" - "github.com/google/generative-ai-go/genai" "github.com/sql-doctor/sql-doctor/internal/ai" - "google.golang.org/api/option" ) -// Client implements ai.AIProvider via the official Google Gemini Go SDK +// Client implements ai.AIProvider for Google Gemini via the official REST API type Client struct { ai.BaseProvider - apiKey string + apiKey string + endpoint string + client *http.Client } +// Request structure for Gemini GenerateContent REST API +type geminiRequest struct { + SystemInstruction *geminiContent `json:"system_instruction,omitempty"` + Contents []geminiContent `json:"contents"` + GenerationConfig *geminiGenConfig `json:"generationConfig,omitempty"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` +} + +type geminiGenConfig struct { + Temperature float64 `json:"temperature,omitempty"` +} + +// Response structure for Gemini GenerateContent REST API +type geminiResponse struct { + Candidates []struct { + Content *struct { + Parts []geminiPart `json:"parts"` + Role string `json:"role"` + } `json:"content"` + FinishReason string `json:"finishReason"` + } `json:"candidates"` + PromptFeedback *struct { + BlockReason string `json:"blockReason"` + } `json:"promptFeedback"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` +} + +// New creates a Google Gemini provider client with default Google API endpoint func New(apiKey, modelName string) *Client { + return NewWithEndpoint(apiKey, modelName, "") +} + +// NewWithEndpoint creates a Google Gemini provider client with a custom endpoint +func NewWithEndpoint(apiKey, modelName, endpoint string) *Client { if modelName == "" { modelName = "gemini-3.8-flash" } + if endpoint == "" { + endpoint = "https://generativelanguage.googleapis.com" + } c := &Client{ - apiKey: apiKey, + apiKey: apiKey, + endpoint: strings.TrimRight(endpoint, "/"), + client: &http.Client{ + Timeout: 45 * time.Second, + }, } c.BaseProvider = ai.BaseProvider{ Name: "Google Gemini", @@ -37,34 +95,104 @@ func (c *Client) callGemini(ctx context.Context, systemPrompt, userPrompt string return "", fmt.Errorf("AI features are unavailable because a Gemini API key has not been configured.\n\nConfigure your key using:\n sql-doctor config ai set-key gemini \nor set the GEMINI_API_KEY environment variable") } - client, err := genai.NewClient(ctx, option.WithAPIKey(c.apiKey)) - if err != nil { - return "", fmt.Errorf("failed to initialize Gemini client: %w", err) + cleanModel := strings.TrimPrefix(strings.TrimSpace(c.ModelName), "models/") + endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent", c.endpoint, cleanModel) + + reqPayload := geminiRequest{ + Contents: []geminiContent{ + { + Role: "user", + Parts: []geminiPart{ + {Text: userPrompt}, + }, + }, + }, + GenerationConfig: &geminiGenConfig{ + Temperature: 0.2, + }, } - defer client.Close() - model := client.GenerativeModel(c.ModelName) if systemPrompt != "" { - model.SystemInstruction = &genai.Content{ - Parts: []genai.Part{genai.Text(systemPrompt)}, + reqPayload.SystemInstruction = &geminiContent{ + Parts: []geminiPart{ + {Text: systemPrompt}, + }, + } + } + + payloadBytes, err := json.Marshal(reqPayload) + if err != nil { + return "", fmt.Errorf("failed to encode request payload: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payloadBytes)) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-goog-api-key", c.apiKey) + + resp, err := c.client.Do(httpReq) + if err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("request timed out or was cancelled: %w", ctx.Err()) } + return "", fmt.Errorf("network connection error calling Google Gemini: %w", err) } + defer resp.Body.Close() - resp, err := model.GenerateContent(ctx, genai.Text(userPrompt)) + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("Gemini API error: %w", err) + return "", fmt.Errorf("failed to read response body: %w", err) + } + + var geminiResp geminiResponse + if err := json.Unmarshal(bodyBytes, &geminiResp); err != nil { + return "", fmt.Errorf("failed to parse Google Gemini response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if geminiResp.Error != nil && geminiResp.Error.Message != "" { + return "", fmt.Errorf("Google Gemini API error (status %d): %s", resp.StatusCode, geminiResp.Error.Message) + } + return "", fmt.Errorf("Google Gemini API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if geminiResp.Error != nil && geminiResp.Error.Message != "" { + return "", fmt.Errorf("Google Gemini API error: %s", geminiResp.Error.Message) } - var sb strings.Builder - for _, cand := range resp.Candidates { + var responseText strings.Builder + var thoughtText strings.Builder + + for _, cand := range geminiResp.Candidates { if cand.Content != nil { for _, part := range cand.Content.Parts { - if txt, ok := part.(genai.Text); ok { - sb.WriteString(string(txt)) + if part.Thought { + thoughtText.WriteString(part.Text) + } else if part.Text != "" { + responseText.WriteString(part.Text) } } } } - return strings.TrimSpace(sb.String()), nil + final := strings.TrimSpace(responseText.String()) + if final == "" { + // Fallback to thoughts if the model reasoned without normal parts + final = strings.TrimSpace(thoughtText.String()) + } + + if final == "" { + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return "", fmt.Errorf("Google Gemini blocked prompt (reason: %s)", geminiResp.PromptFeedback.BlockReason) + } + if len(geminiResp.Candidates) > 0 && geminiResp.Candidates[0].FinishReason != "" && geminiResp.Candidates[0].FinishReason != "STOP" { + return "", fmt.Errorf("Google Gemini stopped generating (finishReason: %s)", geminiResp.Candidates[0].FinishReason) + } + return "", fmt.Errorf("received empty response from Google Gemini") + } + + return final, nil } diff --git a/internal/cli/db.go b/internal/cli/db.go index a511c0c..0f63b85 100644 --- a/internal/cli/db.go +++ b/internal/cli/db.go @@ -186,6 +186,15 @@ var dbRelationshipsCmd = &cobra.Command{ }, } +var diffCmd = &cobra.Command{ + Use: "diff ", + Short: "Compare schemas between two database connections or snapshots", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return dbDiffCmd.RunE(cmd, args) + }, +} + var dbDiffCmd = &cobra.Command{ Use: "diff ", Short: "Compare schemas between two database connections or snapshots", diff --git a/internal/cli/help.go b/internal/cli/help.go index daa1e99..4f45885 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -105,6 +105,7 @@ func renderRootHelp() string { {"ping", "Quick connectivity test to target database"}, {"lint", "Lint SQL query files against anti-pattern rules"}, {"format", "Format SQL queries with standard indentation"}, + {"diff ", "Compare schemas between two database connections"}, {"ask", "Ask database questions or generate SQL using Gemini AI"}, {"completion", "Generate shell autocompletion script"}, } diff --git a/internal/cli/root.go b/internal/cli/root.go index 22eefd3..9c39c2d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -68,6 +68,7 @@ func init() { RootCmd.AddCommand(databasesCmd) RootCmd.AddCommand(pingCmd) RootCmd.AddCommand(configCmd) + RootCmd.AddCommand(diffCmd) RootCmd.AddCommand(dbCmd) RootCmd.AddCommand(queryCmd) RootCmd.AddCommand(schemaCmd) diff --git a/internal/cli/shell.go b/internal/cli/shell.go index 550c113..0a240b7 100644 --- a/internal/cli/shell.go +++ b/internal/cli/shell.go @@ -216,7 +216,7 @@ func printShellHelp(args ...string) { fmt.Println(descStyle.Render("Switch active database for the current connection (in-memory session only).")) fmt.Println() fmt.Println(syntaxStyle.Render("Syntax:") + " use ") - fmt.Println(exampleStyle.Render("Example:") + " use rolerift") + fmt.Println(exampleStyle.Render("Example:") + " use ") case "databases", "dbs": fmt.Println(sectionStyle.Render("COMMAND: databases (or dbs)")) @@ -263,6 +263,15 @@ func printShellHelp(args ...string) { fmt.Println() fmt.Println(syntaxStyle.Render("Syntax:") + " relationships") + case "diff": + fmt.Println(sectionStyle.Render("COMMAND: diff")) + fmt.Println(descStyle.Render("Compare schemas between the active database and another connection profile (or two profiles).")) + fmt.Println() + fmt.Println(syntaxStyle.Render("Syntax:") + " diff (compare current database against target)") + fmt.Println(syntaxStyle.Render(" or:") + " diff ") + fmt.Println(exampleStyle.Render("Example:") + " diff staging") + fmt.Println(exampleStyle.Render("Example:") + " diff prod-db staging-db") + case "analyze": fmt.Println(sectionStyle.Render("COMMAND: analyze")) fmt.Println(descStyle.Render("Run deep performance analysis on a query (measures time, row scans, full table scans, performance score).")) @@ -342,6 +351,7 @@ func printShellHelp(args ...string) { {"describe (or desc) ", "Inspect table columns, types, nullability, keys, and defaults"}, {"indexes
", "List table indexes, uniqueness, and composite column orders"}, {"relationships", "Inspect detected foreign keys and inferred relationships"}, + {"diff ", "Compare active database schema against another connection profile"}, }) printCategory("PERFORMANCE & DIAGNOSTICS", [][2]string{ @@ -361,7 +371,7 @@ func printShellHelp(args ...string) { }) fmt.Println(sectionStyle.Render("EXAMPLES")) - fmt.Printf(" %s\n", exampleStyle.Render("sql-doctor> use rolerift")) + fmt.Printf(" %s\n", exampleStyle.Render("sql-doctor> use ")) fmt.Printf(" %s\n", exampleStyle.Render("sql-doctor> tables")) fmt.Printf(" %s\n", exampleStyle.Render("sql-doctor> select * from users\\G")) fmt.Printf(" %s\n", exampleStyle.Render("sql-doctor> analyze SELECT * FROM users WHERE email = 'test@example.com'")) @@ -658,6 +668,146 @@ func handleShellCommand(ctx context.Context, session *ShellSession, line string) fmt.Println(ui.Success("Database '%s' is responsive. Total tables: %d", session.Config.Database, len(tables))) return nil + case "diff": + if len(parts) < 2 { + return fmt.Errorf("usage: diff OR diff ") + } + if appStorage == nil { + return fmt.Errorf("local storage unavailable") + } + + var ( + connAName string + connBName string + detailsA map[string]*database.TableDetail + detailsB map[string]*database.TableDetail + ) + + if len(parts) == 2 { + if err := EnsureShellDatabase(ctx, session.DB, session.Driver, session.Config); err != nil { + return err + } + connAName = session.Config.Name + if connAName == "" { + connAName = session.Config.Database + } + connBName = parts[1] + + fmt.Println(ui.Info("Fetching schema for active database [%s]...", session.Config.Database)) + dA, err := schema.FetchAllTableDetails(ctx, session.Driver, session.DB) + if err != nil { + return fmt.Errorf("failed to fetch schema for active database: %w", err) + } + detailsA = dA + + recB, err := appStorage.GetConnection(ctx, connBName) + if err != nil { + return fmt.Errorf("connection profile '%s' not found: %w", connBName, err) + } + dbB, driverB, err := database.OpenConnection(ctx, &database.ConnectionConfig{ + Dialect: recB.Dialect, + Host: recB.Host, + Port: recB.Port, + User: recB.User, + Password: recB.Password, + Database: recB.Database, + FilePath: recB.FilePath, + }) + if err != nil { + return fmt.Errorf("failed to connect to '%s': %w", connBName, err) + } + defer dbB.Close() + + fmt.Println(ui.Info("Fetching schema for [%s]...", connBName)) + dB, err := schema.FetchAllTableDetails(ctx, driverB, dbB) + if err != nil { + return fmt.Errorf("failed to fetch schema for '%s': %w", connBName, err) + } + detailsB = dB + } else { + connAName = parts[1] + connBName = parts[2] + + recA, err := appStorage.GetConnection(ctx, connAName) + if err != nil { + return fmt.Errorf("connection profile '%s' not found: %w", connAName, err) + } + dbA, driverA, err := database.OpenConnection(ctx, &database.ConnectionConfig{ + Dialect: recA.Dialect, + Host: recA.Host, + Port: recA.Port, + User: recA.User, + Password: recA.Password, + Database: recA.Database, + FilePath: recA.FilePath, + }) + if err != nil { + return fmt.Errorf("failed to connect to '%s': %w", connAName, err) + } + defer dbA.Close() + + recB, err := appStorage.GetConnection(ctx, connBName) + if err != nil { + return fmt.Errorf("connection profile '%s' not found: %w", connBName, err) + } + dbB, driverB, err := database.OpenConnection(ctx, &database.ConnectionConfig{ + Dialect: recB.Dialect, + Host: recB.Host, + Port: recB.Port, + User: recB.User, + Password: recB.Password, + Database: recB.Database, + FilePath: recB.FilePath, + }) + if err != nil { + return fmt.Errorf("failed to connect to '%s': %w", connBName, err) + } + defer dbB.Close() + + fmt.Println(ui.Info("Fetching schema for [%s]...", connAName)) + dA, err := schema.FetchAllTableDetails(ctx, driverA, dbA) + if err != nil { + return err + } + detailsA = dA + + fmt.Println(ui.Info("Fetching schema for [%s]...", connBName)) + dB, err := schema.FetchAllTableDetails(ctx, driverB, dbB) + if err != nil { + return err + } + detailsB = dB + } + + diffEngine := schema.NewDiffEngine() + diff := diffEngine.Compare(detailsA, detailsB) + diff.SourceSchema = connAName + diff.TargetSchema = connBName + + fmt.Println() + fmt.Println(ui.TitleStyle.Render(fmt.Sprintf("Schema Diff: [%s] vs [%s]", connAName, connBName))) + if len(diff.Differences) == 0 { + fmt.Println(ui.Success("Schemas are identical! No differences found.")) + return nil + } + + fmt.Printf("Total Differences: %d\n\n", diff.TotalDiffs) + tbl := ui.NewTable("ACTION", "TYPE", "OBJECT", "DETAILS") + for _, item := range diff.Differences { + actionBadge := item.Action + switch item.Action { + case "ADDED": + actionBadge = ui.SuccessBadge + " ADDED" + case "REMOVED": + actionBadge = ui.CriticalBadge + " REMOVED" + case "MODIFIED": + actionBadge = ui.WarningBadge + " MODIFIED" + } + tbl.AddRow(actionBadge, item.Type, item.ObjectName, item.Details) + } + fmt.Println(tbl.Render()) + return nil + case "ask": if len(parts) < 2 { return fmt.Errorf("usage: ask ") diff --git a/internal/config/config.go b/internal/config/config.go index 2e3a726..5225c20 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,6 +100,12 @@ func LoadConfig(ctx context.Context, store *storage.Storage) (*Config, error) { cfg.GeminiModel = storedModel } } + if cfg.GeminiModel == "gemini-2.5-flash" || cfg.GeminiModel == "gemini-1.5-flash" || cfg.GeminiModel == "gemini-1.5-pro" { + cfg.GeminiModel = "gemini-3.8-flash" + if store != nil { + _ = store.SetSetting(ctx, "gemini_model", "gemini-3.8-flash") + } + } // 3. OpenAI if envKey := os.Getenv("OPENAI_API_KEY"); envKey != "" { diff --git a/tests/unit/ai_providers_test.go b/tests/unit/ai_providers_test.go index 91917d0..51f1c83 100644 --- a/tests/unit/ai_providers_test.go +++ b/tests/unit/ai_providers_test.go @@ -10,6 +10,7 @@ import ( "github.com/sql-doctor/sql-doctor/internal/ai" "github.com/sql-doctor/sql-doctor/internal/ai/claude" + "github.com/sql-doctor/sql-doctor/internal/ai/gemini" "github.com/sql-doctor/sql-doctor/internal/ai/openai" "github.com/sql-doctor/sql-doctor/internal/cli" "github.com/sql-doctor/sql-doctor/internal/config" @@ -108,6 +109,44 @@ func TestOpenAIClientMock(t *testing.T) { } } +func TestGeminiClientMock(t *testing.T) { + ctx := context.Background() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-goog-api-key") != "gemini-test-secret" { + t.Errorf("missing or invalid x-goog-api-key header") + } + + resp := map[string]interface{}{ + "candidates": []map[string]interface{}{ + { + "content": map[string]interface{}{ + "role": "model", + "parts": []map[string]interface{}{ + { + "text": "Thinking through...", + "thought": true, + }, + { + "text": "PONG", + "thought": false, + }, + }, + }, + "finishReason": "STOP", + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := gemini.NewWithEndpoint("gemini-test-secret", "gemini-3.8-flash", server.URL) + if err := client.TestConnection(ctx); err != nil { + t.Fatalf("TestConnection failed: %v", err) + } +} + func TestClaudeClientMock(t *testing.T) { client := claude.New("ant-test-secret", "claude-3-5-haiku-20241022") if !client.IsConfigured() {