Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <database_name>
```

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@<database_name>]> tables
sql-doctor [mysql@<database_name>]> select * from users;
sql-doctor [mysql@<database_name>]> select * from users\G # Vertical format (one column per line)
sql-doctor [mysql@<database_name>]> use <other_database> # Switch database on the fly
sql-doctor [mysql@<other_database>]> analyze SELECT * FROM orders WHERE status = 'pending'
sql-doctor [mysql@<other_database>]> help # View categorized commands
sql-doctor [mysql@<other_database>]> 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.
Expand All @@ -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/<database_name>" 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.
Expand All @@ -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 <database_name>

# Clear session memory
sql-doctor disconnect
Expand Down
166 changes: 147 additions & 19 deletions internal/ai/gemini/client.go
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 <your-key>\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
}
9 changes: 9 additions & 0 deletions internal/cli/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ var dbRelationshipsCmd = &cobra.Command{
},
}

var diffCmd = &cobra.Command{
Use: "diff <connA> <connB>",
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 <connA> <connB>",
Short: "Compare schemas between two database connections or snapshots",
Expand Down
1 change: 1 addition & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <connA> <connB>", "Compare schemas between two database connections"},
{"ask", "Ask database questions or generate SQL using Gemini AI"},
{"completion", "Generate shell autocompletion script"},
}
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading