diff --git a/README.md b/README.md index 7477cdc..6cb8cb3 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ - **In-Place Editing** - Update cells, delete rows and edit your SQL directly from the results table - **Export your data** - Export your data as CSV, JSON, SQL, Markdown or HTML tables - **Connection Switching** - Manage multiple databases and switch whenever you want -- **Database Exploration** - Browse schema, visualize foreign key relationships +- **Database Exploration** - Interactive schema explorer with search and foldable sections, plus foreign-key relationship trees - **Interactive Shell** - REPL with history, multi-line, and meta-commands See [Features](docs/features.md) for details and examples @@ -293,6 +293,8 @@ See [Shell Completion](docs/completion.md) Row limits, column widths, color schemes (`dracula`, `gruvbox`, `catppuccin-mocha`, etc.) and UI visibility options can be set through the config file at `~/.config/squix/config.yaml`. +Connections named `{service}:{env}` (e.g. `ecommerce:dev`, `ecommerce:prod`) share one saved-query library — add a query once, run it on any sibling environment. See [Shared query libraries](docs/configuration.md#shared-query-libraries). + See [Configuration](docs/configuration.md) --- @@ -348,7 +350,7 @@ as part of your workflow. - [x] Move saved configs from top level yaml file to per-connection sql files - [x] Display counters for selected lines and search results - [x] Allow different connections to share query collection (eg. dev, stg, prod) -- [ ] Rework database exploration commands +- [x] Rework database exploration commands - [ ] Option to encrypt sensitive data in the config file --- diff --git a/SKILL.md b/SKILL.md index a4baf68..f589017 100644 --- a/SKILL.md +++ b/SKILL.md @@ -9,7 +9,9 @@ description: Run SQL queries across databases (Postgres, MySQL, SQLite, etc.) vi Squix is a CLI tool for managing and executing SQL queries across multiple databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server, ClickHouse, Firebird). -It stores named queries in `~/.config/squix/config.yaml`. +It stores named queries as `.sql` files in `~/.config/squix/queries/`. + +**Shared query libraries:** Connections named `{service}:{env}` (e.g. `ecommerce:dev`, `ecommerce:prod`) share one query library keyed by service — a query added on one is visible on siblings. `conn_string` stays per-connection. See [docs/configuration.md](docs/configuration.md#shared-query-libraries). ## Critical Rules @@ -47,10 +49,17 @@ squix run list_users -f json # always use -f ## Explore Schema ```bash -squix explore # List all tables (non-interactive, prints to stdout) -squix explain orders -d 2 # Show FK relationships as ASCII tree +squix explore # Interactive schema explorer TUI (tables + views) +squix explore --oneline # Non-interactive: print table/view names one per line +squix explore --oneline --tables # tables only (--views for views only) +squix explain orders -d 2 # Show FK relationships as ASCII tree ``` +Explorer TUI keys: `enter`=SELECT *, `c`=columns, `r`=relationships, +`Tab`=fold section, `/`=search. Defaults can be overridden by adding +`explorer_*` entries under `keybindings:` in config.yaml — see +docs/keybindings.md. + ## List Queries ```bash @@ -82,7 +91,7 @@ echo y | squix remove -c mydb # Delete connection (needs confirmation) - `squix run ` without `-f` on a SELECT — opens TUI - `squix shell` — interactive REPL - `squix edit` — opens `$EDITOR` +- `squix explore` — interactive schema explorer TUI (use `--oneline`) - `squix explore ` — opens TUI -- `squix info` — opens TUI - `squix add ` without SQL — opens `$EDITOR` - `squix run` with no args — opens `$EDITOR` diff --git a/cmd/squix/add.go b/cmd/squix/add.go index 42459a1..8bae370 100644 --- a/cmd/squix/add.go +++ b/cmd/squix/add.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/editor" "github.com/eduardofuncao/squix/internal/styles" @@ -20,15 +19,7 @@ func (a *App) handleAdd() { printError("No active connection. Use 'squix switch ' or 'squix init' first") } - _, ok := a.config.Connections[a.config.CurrentConnection] - if !ok { - a.config.Connections[a.config.CurrentConnection] = &config.ConnectionYAML{} - } - conn := a.config.Connections[a.config.CurrentConnection] - if conn.Queries == nil { - conn.Queries = make(map[string]db.Query) - } - queries := conn.Queries + queries := a.config.QueriesFor(a.config.CurrentConnection) queryName := os.Args[2] var querySQL string @@ -68,6 +59,5 @@ func (a *App) handleAdd() { printError("Could not save configuration file: %v", err) } - fmt.Println(styles.Success.Render(fmt.Sprintf("✓ Added query '%s' with ID %d", queryName, queries[queryName].Id))) + fmt.Println(styles.Success.Render(fmt.Sprintf("✓ Added query '%s'", queryName))) } - diff --git a/cmd/squix/app.go b/cmd/squix/app.go index 073d798..815362c 100644 --- a/cmd/squix/app.go +++ b/cmd/squix/app.go @@ -52,16 +52,12 @@ func (a *App) Run() { a.handleListConnections() case "edit": a.handleEdit() - case "info": - a.handleInfo() case "explore": a.handleExplore() case "status", "st", "test": a.handleStatus() case "history": a.handleHistory() - case "tables", "t": - a.handleTables() case "disconnect", "clear", "unset": a.handleDisconnect() case "config": @@ -107,10 +103,12 @@ func (a *App) printUsage() { ), ) fmt.Println( - " squix tables " + styles.Faint.Render("List database tables"), + " squix explore " + styles.Faint.Render( + "Explore database schema", + ), ) fmt.Println( - " squix tables
" + styles.Faint.Render( + " squix explore
" + styles.Faint.Render( "Query a table directly", ), ) diff --git a/cmd/squix/completion.go b/cmd/squix/completion.go index e0fb0b9..cad01a9 100644 --- a/cmd/squix/completion.go +++ b/cmd/squix/completion.go @@ -94,11 +94,8 @@ func getCompletions(args []string, cfg *config.Config) []string { result := []string{"queries", "connections"} result = append(result, getCurrentConnectionQueries(cfg)...) return result - case "info": - if len(args) >= 2 { - return []string{"table", "view"} - } - return []string{"table", "view"} + case "explore": + return []string{"--oneline", "--tables", "--views"} case "edit", "delete", "rm", "remove": return getCurrentConnectionQueries(cfg) case "--connection", "-c": @@ -123,13 +120,10 @@ func getSubcommands() []string { "list", "ls", "edit", - "info", "explore", "status", "test", "history", - "tables", - "t", "disconnect", "clear", "unset", @@ -154,13 +148,12 @@ func getCurrentConnectionQueries(cfg *config.Config) []string { return []string{} } - conn, exists := cfg.Connections[cfg.CurrentConnection] - if !exists { + if _, exists := cfg.Connections[cfg.CurrentConnection]; !exists { return []string{} } var names []string - for name := range conn.Queries { + for name := range cfg.QueriesFor(cfg.CurrentConnection) { names = append(names, name) } sort.Strings(names) diff --git a/cmd/squix/edit.go b/cmd/squix/edit.go index 3be8704..c0de7bd 100644 --- a/cmd/squix/edit.go +++ b/cmd/squix/edit.go @@ -8,6 +8,7 @@ import ( "os/exec" "strings" + "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/editor" "github.com/eduardofuncao/squix/internal/styles" @@ -31,23 +32,19 @@ func (a *App) editSingleQuery(selector string) { log.Fatal("No active connection. Use 'squix switch ' or 'squix init' first") } - conn, ok := a.config.Connections[a.config.CurrentConnection] - if !ok { + if _, ok := a.config.Connections[a.config.CurrentConnection]; !ok { log.Fatalf("Connection %s not found", a.config.CurrentConnection) } + queries := a.config.QueriesFor(a.config.CurrentConnection) // Find the query - query, exists := db.FindQueryWithSelector(conn.Queries, selector) + query, exists := db.FindQueryWithSelector(queries, selector) if !exists { log.Fatalf("Query '%s' not found in connection '%s'", selector, a.config.CurrentConnection) } - // Create temp file with the query SQL - var content strings.Builder - content.WriteString(fmt.Sprintf("-- %s\n", query.Name)) - content.WriteString(query.SQL) - - tmpFile, err := editor.CreateTempFile("squix-edit-query-", content.String()) + // Render the single query block (round-trips table/pk metadata too). + tmpFile, err := editor.CreateTempFile("squix-edit-query-", config.RenderQuery(query)) if err != nil { log.Fatalf("Failed to create temp file: %v", err) } @@ -55,7 +52,6 @@ func (a *App) editSingleQuery(selector string) { defer os.Remove(tmpPath) tmpFile.Close() - // Open editor editorCmd, err := editor.CheckEditor() if err != nil { log.Fatal(err) @@ -74,32 +70,40 @@ func (a *App) editSingleQuery(selector string) { log.Fatalf("Failed to read edited file: %v", err) } - newName, newSQL, err := parseSingleQueryFile(editedData) + parsed, err := config.ParseGroupFile([]byte(editedData)) if err != nil { log.Fatalf("Failed to parse edited query: %v", err) } + if len(parsed) != 1 { + log.Fatalf("Expected exactly one query block, got %d", len(parsed)) + } + var edited db.Query + for _, q := range parsed { + edited = q + } - if newName != query.Name { - if !a.confirmQueryRename(query.Name, newName) { + oldName := query.Name + if edited.Name != oldName { + if !a.confirmQueryRename(oldName, edited.Name) { fmt.Println(styles.Faint.Render("Aborted")) return } - - delete(conn.Queries, query.Name) + delete(queries, oldName) } - // Update query - query.Name = newName - query.SQL = newSQL - conn.Queries[query.Name] = query - a.config.Connections[a.config.CurrentConnection] = conn + // Update query (preserve id; reassign the rest from the edited block). + query.Name = edited.Name + query.SQL = edited.SQL + query.TableName = edited.TableName + query.PrimaryKeys = edited.PrimaryKeys + queries[query.Name] = query if err := a.config.Save(); err != nil { log.Fatalf("Failed to save config: %v", err) } - if newName != query.Name { - fmt.Printf("✓ Renamed and updated query '%s' → '%s'\n", query.Name, newName) + if edited.Name != oldName { + fmt.Printf("✓ Renamed and updated query '%s' → '%s'\n", oldName, edited.Name) } else { fmt.Printf("✓ Updated query '%s'\n", query.Name) } @@ -124,19 +128,14 @@ func (a *App) editQueriesWithEditor(editorCmd string) { if !ok { log.Fatalf("Connection %s not found", a.config.CurrentConnection) } + queries := a.config.QueriesFor(a.config.CurrentConnection) var content strings.Builder - content.WriteString(fmt.Sprintf("-- Editing queries for connection: %s (%s)\n", - a.config.CurrentConnection, conn.DBType)) - content.WriteString("-- Format: -- runname\n") - content.WriteString("-- SQL run here\n") + fmt.Fprintf(&content, "-- Editing queries for connection: %s (%s)\n", + a.config.CurrentConnection, conn.DBType) + content.WriteString("-- Format: -- @query (+ optional -- @table / -- @pk)\n") content.WriteString("-- Save and close to update\n\n") - - for _, query := range conn.Queries { - content.WriteString(fmt.Sprintf("-- %s\n", query.Name)) - content.WriteString(strings.TrimSpace(query.SQL)) - content.WriteString("\n\n") - } + content.WriteString(config.RenderGroup(queries)) tmpFile, err := editor.CreateTempFile("squix-queries-", content.String()) if err != nil { @@ -159,13 +158,14 @@ func (a *App) editQueriesWithEditor(editorCmd string) { log.Fatalf("Failed to read edited file: %v", err) } - editedQueries, err := parseSQLQueriesFile(editedData) + editedQueries, err := config.ParseGroupFile([]byte(editedData)) if err != nil { log.Fatalf("Failed to parse edited queries: %v", err) } + config.AssignIDs(editedQueries) - conn.Queries = editedQueries - a.config.Connections[a.config.CurrentConnection] = conn + // Replace the shared library in place so sibling connections see the change. + a.config.SetQueriesFor(a.config.CurrentConnection, editedQueries) if err := a.config.Save(); err != nil { log.Fatalf("Failed to save config: %v", err) @@ -174,64 +174,6 @@ func (a *App) editQueriesWithEditor(editorCmd string) { fmt.Printf("✓ Updated queries for connection: %s\n", a.config.CurrentConnection) } -// parseSingleQueryFile parses a file containing a single query -// Expected format: -// -// -- queryname -// SQL query here -func parseSingleQueryFile(content string) (string, string, error) { - lines := strings.Split(strings.TrimSpace(content), "\n") - if len(lines) == 0 { - return "", "", fmt.Errorf("empty file") - } - - // First non-empty line should be the query name comment - var queryName string - var sqlLines []string - foundName := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - // Skip empty lines at the start - if !foundName && trimmed == "" { - continue - } - - // Look for query name comment - if !foundName { - if comment, ok := strings.CutPrefix(trimmed, "--"); ok { - queryName = strings.TrimSpace(comment) - foundName = true - continue - } else { - // First non-empty line is not a comment - // Treat entire content as SQL with no name change - queryName = "" // Will trigger error if not provided - break - } - } - - // Rest is SQL - if foundName { - if !strings.HasPrefix(trimmed, "--") { - sqlLines = append(sqlLines, line) - } - } - } - - if queryName == "" { - return "", "", fmt.Errorf("query name not found (expected '-- queryname' on first line)") - } - - if len(sqlLines) == 0 { - return queryName, "", fmt.Errorf("no SQL content found") - } - - sql := strings.Join(sqlLines, "\n") - return queryName, sql, nil -} - func (a *App) confirmQueryRename(oldName, newName string) bool { reader := bufio.NewReader(os.Stdin) fmt.Print(styles.Error.Render(fmt.Sprintf("Rename query '%s' → '%s'? [y/N]: ", oldName, newName))) @@ -244,51 +186,3 @@ func (a *App) confirmQueryRename(oldName, newName string) bool { response = strings.TrimSpace(strings.ToLower(response)) return response == "y" || response == "yes" } - -// parseSQLQueriesFile parses a SQL file with the format: -// -- queryname -// SQL query here -func parseSQLQueriesFile(content string) (map[string]db.Query, error) { - queries := make(map[string]db.Query) - var name string - var sql strings.Builder - id := 1 - - save := func() { - if name != "" && sql.Len() > 0 { - queries[name] = db.Query{Name: name, SQL: strings.TrimSpace(sql.String()), Id: id} - id++ - sql.Reset() - } - } - - for line := range strings.SplitSeq(content, "\n") { - trimmed := strings.TrimSpace(line) - - // Check for query name comment - if comment, ok := strings.CutPrefix(trimmed, "--"); ok { - comment = strings.TrimSpace(comment) - - // Skip help comments - if strings.HasPrefix(comment, "Editing") || strings.HasPrefix(comment, "Format") || - strings.HasPrefix(comment, "SQL") || strings.HasPrefix(comment, "Save") { - continue - } - - save() - name = comment - continue - } - - // Add SQL line - if name != "" { - if sql.Len() > 0 { - sql.WriteString("\n") - } - sql.WriteString(line) - } - } - - save() - return queries, nil -} diff --git a/cmd/squix/explain.go b/cmd/squix/explain.go index e2ee344..be08dda 100644 --- a/cmd/squix/explain.go +++ b/cmd/squix/explain.go @@ -6,10 +6,7 @@ import ( "strconv" "strings" - "charm.land/lipgloss/v2" - "github.com/eduardofuncao/squix/internal/config" - "github.com/eduardofuncao/squix/internal/db" - "github.com/eduardofuncao/squix/internal/styles" + "github.com/eduardofuncao/squix/internal/explain" ) type explainFlags struct { @@ -19,7 +16,7 @@ type explainFlags struct { func parseExplainFlags() (explainFlags, []string) { flags := explainFlags{ - depth: 1, // Default to showing just direct relationships + depth: 1, } remainingArgs := []string{} args := os.Args[2:] @@ -31,7 +28,7 @@ func parseExplainFlags() (explainFlags, []string) { if depth, err := strconv.Atoi(args[i+1]); err == nil { flags.depth = depth } - i++ // Skip the next argument + i++ } } else if arg == "--verbose" || arg == "-v" { flags.verbose = true @@ -57,9 +54,7 @@ func (a *App) handleExplain() { os.Exit(1) } - conn := config.FromConnectionYaml( - a.config.Connections[a.config.CurrentConnection], - ) + conn := a.config.LiveConnection(a.config.CurrentConnection) if err := conn.Open(); err != nil { printError( @@ -72,436 +67,5 @@ func (a *App) handleExplain() { tableName := args[0] - // Cache for FK lookups to improve performance - fkCache := make(map[string][]db.ForeignKey) - visited := make(map[string]bool) - - tree := a.buildRelationshipTree(conn, tableName, flags.depth, 0, visited, fkCache, flags.verbose) - - fmt.Println(tree) -} - -type relationshipType int - -const ( - belongsTo relationshipType = iota - hasMany - hasOne - hasManyToMany -) - -type relationship struct { - relType relationshipType - column string - referencedTable string - referencedColumn string - junctionTable string // For N:N relationships -} - -func (a *App) buildRelationshipTree(conn db.DatabaseConnection, tableName string, maxDepth, currentDepth int, visited map[string]bool, fkCache map[string][]db.ForeignKey, verbose bool) string { - if currentDepth >= maxDepth { - return "" - } - - var relationships []relationship - seenTables := make(map[string]bool) - - // Get unique constraints for this table (for 1:1 detection) - uniqueConstraints, _ := conn.GetUniqueConstraints(tableName) - uniqueMap := make(map[string]bool) - for _, uc := range uniqueConstraints { - uniqueMap[uc] = true - } - - // Get "belongs to" relationships (FKs from this table to other tables) - belongsToFKs, err := conn.GetForeignKeys(tableName) - if err == nil { - for _, fk := range belongsToFKs { - // Create a unique key for deduplication - key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) - if seenTables[key] { - continue - } - seenTables[key] = true - - // Check if FK column has UNIQUE constraint (1:1 relationship) - relType := belongsTo - if uniqueMap[fk.Column] { - relType = hasOne - } - - relationships = append(relationships, relationship{ - relType: relType, - column: fk.Column, - referencedTable: fk.ReferencedTable, - referencedColumn: fk.ReferencedColumn, - }) - } - } - - // Get "has many" relationships (FKs from other tables to this table) - hasManyFKs, err := conn.GetForeignKeysReferencingTable(tableName) - if err == nil { - for _, fk := range hasManyFKs { - // Check if the referencing table is a junction table (N:N detection) - if a.isJunctionTable(conn, fk.ReferencedTable) { - // Get the other side of the N:N relationship - otherTable := a.getJunctionTableOtherSide(conn, fk.ReferencedTable, tableName) - if otherTable != "" { - // Create a unique key for deduplication - key := fmt.Sprintf("nn:%s:%s:%s", otherTable, fk.ReferencedTable, tableName) - if seenTables[key] { - continue - } - seenTables[key] = true - - relationships = append(relationships, relationship{ - relType: hasManyToMany, - referencedTable: otherTable, - junctionTable: fk.ReferencedTable, - }) - } - } else { - // Check if FK column in referencing table has UNIQUE constraint (1:1 relationship) - otherTableUnique, _ := conn.GetUniqueConstraints(fk.ReferencedTable) - isOneToOne := false - for _, uc := range otherTableUnique { - if uc == fk.Column { - isOneToOne = true - break - } - } - - // Create a unique key for deduplication - key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) - if seenTables[key] { - continue - } - seenTables[key] = true - - relType := hasMany - if isOneToOne { - relType = hasOne - } - - relationships = append(relationships, relationship{ - relType: relType, - column: fk.Column, - referencedTable: fk.ReferencedTable, - referencedColumn: fk.ReferencedColumn, - }) - } - } - } - - return a.renderNode(conn, tableName, relationships, maxDepth, currentDepth, visited, fkCache, true, verbose) -} - -// isJunctionTable checks if a table is a junction table for N:N relationship -func (a *App) isJunctionTable(conn db.DatabaseConnection, tableName string) bool { - fks, err := conn.GetForeignKeys(tableName) - if err != nil || len(fks) != 2 { - return false - } - - // Check if both FKs are part of the primary key (composite PK) - metadata, err := conn.GetTableMetadata(tableName) - if err != nil { - return false - } - - // Check if both FK columns are in the primary key - pkMap := make(map[string]bool) - for _, pk := range metadata.PrimaryKeys { - pkMap[pk] = true - } - - bothInPK := true - for _, fk := range fks { - if !pkMap[fk.Column] { - bothInPK = false - break - } - } - - return bothInPK -} - -// getJunctionTableOtherSide returns the other table referenced by a junction table -func (a *App) getJunctionTableOtherSide(conn db.DatabaseConnection, junctionTable, currentTable string) string { - fks, err := conn.GetForeignKeys(junctionTable) - if err != nil || len(fks) != 2 { - return "" - } - - // Return the table that is not the current table - for _, fk := range fks { - if fk.ReferencedTable != currentTable { - return fk.ReferencedTable - } - } - - return "" -} - -func (a *App) renderNode(conn db.DatabaseConnection, tableName string, relationships []relationship, maxDepth, currentDepth int, visited map[string]bool, fkCache map[string][]db.ForeignKey, isRoot bool, verbose bool) string { - var builder strings.Builder - - // Render current table with PK info (only at root level) - if isRoot { - metadata, _ := conn.GetTableMetadata(tableName) - if len(metadata.PrimaryKeys) > 0 { - pks := strings.Join(metadata.PrimaryKeys, ", ") - builder.WriteString(styles.TableName.Render(tableName)) - builder.WriteString(" ") - builder.WriteString(styles.PrimaryKeyLabel.Render(fmt.Sprintf("(PK: %s)", pks))) - } else { - builder.WriteString(styles.TableName.Render(tableName)) - } - builder.WriteString("\n") - } - - // Mark current table as visited to prevent cycles - visited[tableName] = true - - // Check if we should render relationships at this depth - if currentDepth >= maxDepth && !isRoot { - return builder.String() - } - - for i, rel := range relationships { - isLast := i == len(relationships)-1 - prefix := "├── " - if isLast { - prefix = "└── " - } - - // Determine relationship type and styling - var relText, cardinality, fkDetails string - var relStyle lipgloss.Style - - isSelfReference := (rel.referencedTable == tableName) - - if rel.relType == belongsTo { - relText = "belongs to" - cardinality = "[N:1]" - relStyle = styles.BelongsToStyle - if verbose { - fkDetails = fmt.Sprintf("(FK: %s → %s.%s)", rel.column, rel.referencedTable, rel.referencedColumn) - } - } else if rel.relType == hasOne { - relText = "has one" - cardinality = "[1:1]" - relStyle = styles.HasOneStyle - if verbose { - fkDetails = fmt.Sprintf("(FK: %s → %s.%s)", rel.column, rel.referencedTable, rel.referencedColumn) - } - } else if rel.relType == hasMany { - relText = "has many" - cardinality = "[1:N]" - relStyle = styles.HasManyStyle - if verbose { - fkDetails = fmt.Sprintf("(on: %s ← %s.%s)", rel.referencedColumn, rel.referencedTable, rel.column) - } - } else if rel.relType == hasManyToMany { - relText = "↔" - cardinality = "[N:N]" - relStyle = styles.HasManyToManyStyle - if verbose { - fkDetails = fmt.Sprintf("(via %s)", rel.junctionTable) - } - } - - // Render relationship line - builder.WriteString(styles.TreeConnector.Render(prefix)) - if rel.relType == hasManyToMany { - // Special rendering for N:N - builder.WriteString(relStyle.Render(relText)) - builder.WriteString(" ") - builder.WriteString(styles.TableName.Render(rel.referencedTable)) - builder.WriteString(" ") - builder.WriteString(styles.CardinalityStyle.Render(cardinality)) - } else { - builder.WriteString(relStyle.Render(fmt.Sprintf("%s →", relText))) - builder.WriteString(" ") - builder.WriteString(styles.TableName.Render(rel.referencedTable)) - builder.WriteString(" ") - builder.WriteString(styles.CardinalityStyle.Render(cardinality)) - } - - if verbose && fkDetails != "" { - builder.WriteString(" ") - builder.WriteString(styles.Faint.Render(fkDetails)) - } - - if isSelfReference { - builder.WriteString(" " + styles.Faint.Render("(self-reference)")) - } - - builder.WriteString("\n") - - // Don't show children for self-references or N:N relationships (collapsed) - if isSelfReference || rel.relType == hasManyToMany { - continue - } - - // Don't revisit tables - if visited[rel.referencedTable] { - continue - } - - // Recursively render children if within depth limit - if currentDepth+1 <= maxDepth { - childRelationships := a.getChildRelationships(conn, rel.referencedTable) - if len(childRelationships) > 0 { - childPrefix := " " - if !isLast { - childPrefix = "│ " - } - - // Create local visited map for this branch to prevent cycles - localVisited := make(map[string]bool) - for k, v := range visited { - localVisited[k] = v - } - localVisited[rel.referencedTable] = true - - childTree := a.renderNode(conn, rel.referencedTable, childRelationships, maxDepth, currentDepth+1, localVisited, fkCache, false, verbose) - lines := strings.Split(childTree, "\n") - for _, line := range lines { - if line == "" { - continue - } - builder.WriteString(styles.TreeConnector.Render(childPrefix)) - builder.WriteString(line) - builder.WriteString("\n") - } - } - } - } - - return builder.String() -} - -func (a *App) getChildRelationships(conn db.DatabaseConnection, tableName string) []relationship { - var relationships []relationship - seenTables := make(map[string]bool) - - // Get unique constraints for this table (for 1:1 detection) - uniqueConstraints, _ := conn.GetUniqueConstraints(tableName) - uniqueMap := make(map[string]bool) - for _, uc := range uniqueConstraints { - uniqueMap[uc] = true - } - - // Get "belongs to" relationships - belongsToFKs, err := conn.GetForeignKeys(tableName) - if err == nil { - for _, fk := range belongsToFKs { - key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) - if !seenTables[key] { - seenTables[key] = true - - // Check if FK column has UNIQUE constraint (1:1 relationship) - relType := belongsTo - if uniqueMap[fk.Column] { - relType = hasOne - } - - relationships = append(relationships, relationship{ - relType: relType, - column: fk.Column, - referencedTable: fk.ReferencedTable, - referencedColumn: fk.ReferencedColumn, - }) - } - } - } - - // Get "has many" relationships - hasManyFKs, err := conn.GetForeignKeysReferencingTable(tableName) - if err == nil { - for _, fk := range hasManyFKs { - // Check if the referencing table is a junction table (N:N detection) - if a.isJunctionTable(conn, fk.ReferencedTable) { - // Get the other side of the N:N relationship - otherTable := a.getJunctionTableOtherSide(conn, fk.ReferencedTable, tableName) - if otherTable != "" { - // Create a unique key for deduplication - key := fmt.Sprintf("nn:%s:%s:%s", otherTable, fk.ReferencedTable, tableName) - if !seenTables[key] { - seenTables[key] = true - - relationships = append(relationships, relationship{ - relType: hasManyToMany, - referencedTable: otherTable, - junctionTable: fk.ReferencedTable, - }) - } - } - } else { - // Check if FK column in referencing table has UNIQUE constraint (1:1 relationship) - otherTableUnique, _ := conn.GetUniqueConstraints(fk.ReferencedTable) - isOneToOne := false - for _, uc := range otherTableUnique { - if uc == fk.Column { - isOneToOne = true - break - } - } - - key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) - if !seenTables[key] { - seenTables[key] = true - - relType := hasMany - if isOneToOne { - relType = hasOne - } - - relationships = append(relationships, relationship{ - relType: relType, - column: fk.Column, - referencedTable: fk.ReferencedTable, - referencedColumn: fk.ReferencedColumn, - }) - } - } - } - } - - return relationships -} - -func (a *App) renderRelationshipLine(rel relationship, parentTable string, isSelfReference bool) string { - var builder strings.Builder - - var relText, cardinality, fkDetails string - var relStyle lipgloss.Style - - if rel.relType == belongsTo { - relText = "belongs to" - cardinality = "[N:1]" - relStyle = styles.BelongsToStyle - fkDetails = fmt.Sprintf("(FK: %s → %s.%s)", rel.column, rel.referencedTable, rel.referencedColumn) - } else { - relText = "has many" - cardinality = "[1:N]" - relStyle = styles.HasManyStyle - fkDetails = fmt.Sprintf("(on: %s ← %s.%s)", rel.referencedColumn, rel.referencedTable, rel.column) - } - - builder.WriteString(relStyle.Render(fmt.Sprintf("%s →", relText))) - builder.WriteString(" ") - builder.WriteString(styles.CardinalityStyle.Render(cardinality)) - builder.WriteString(" ") - builder.WriteString(styles.TableName.Render(rel.referencedTable)) - builder.WriteString(" ") - builder.WriteString(styles.Faint.Render(fkDetails)) - - if isSelfReference { - builder.WriteString(" " + styles.Faint.Render("(self-reference)")) - } - - return builder.String() + fmt.Println(explain.BuildTree(conn, tableName, flags.depth, flags.verbose)) } diff --git a/cmd/squix/explore.go b/cmd/squix/explore.go index b1390ab..79149af 100644 --- a/cmd/squix/explore.go +++ b/cmd/squix/explore.go @@ -8,61 +8,195 @@ import ( "strconv" "strings" - "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" + "github.com/eduardofuncao/squix/internal/explorer" "github.com/eduardofuncao/squix/internal/run" + "github.com/eduardofuncao/squix/internal/spinner" "github.com/eduardofuncao/squix/internal/styles" "github.com/eduardofuncao/squix/internal/table" ) -func (a *App) handleExplore() { - if len(os.Args) < 3 { - a.listTablesAndViews() - return +type exploreFlags struct { + oneline bool + tablesOnly bool + viewsOnly bool + limit int +} + +func parseExploreFlags() (exploreFlags, []string) { + flags := exploreFlags{} + remainingArgs := []string{} + args := os.Args[2:] + + for i := 0; i < len(args); i++ { + arg := args[i] + switch arg { + case "--oneline", "-o": + flags.oneline = true + case "--tables": + flags.tablesOnly = true + case "--views": + flags.viewsOnly = true + case "--limit", "-l": + if i+1 < len(args) { + if parsed, err := strconv.Atoi(args[i+1]); err == nil { + flags.limit = parsed + } + i++ + } + default: + if !strings.HasPrefix(arg, "-") { + remainingArgs = append(remainingArgs, arg) + } + } } - // Check if argument is a supported file type - if ext, ok := getSupportedExtension(os.Args[2]); ok { - if _, err := os.Stat(os.Args[2]); err != nil { - printError("File not found: %s", os.Args[2]) + return flags, remainingArgs +} + +func (a *App) handleExplore() { + flags, args := parseExploreFlags() + + if len(args) > 0 && isSupportedFile(args[0]) { + if _, err := os.Stat(args[0]); err != nil { + printError("File not found: %s", args[0]) } - if err := a.handleExploreFile(os.Args[2], ext); err != nil { + if err := a.handleExploreFile(args[0]); err != nil { printError("%v", err) } return } - tableName := os.Args[2] - limit := a.config.DefaultRowLimit + if a.config.CurrentConnection == "" { + printError("No active connection. Use 'squix switch ' or 'squix init' first") + } - // Parse optional -l/--limit flag - for i := 3; i < len(os.Args); i++ { - if os.Args[i] == "-l" || os.Args[i] == "--limit" { - if i+1 < len(os.Args) { - parsed, err := strconv.Atoi(os.Args[i+1]) - if err != nil { - printError("Invalid limit value: %s", os.Args[i+1]) - } - limit = parsed + conn := a.config.LiveConnection(a.config.CurrentConnection) + + if err := conn.Open(); err != nil { + printError( + "Could not open connection to %s: %v", + a.config.CurrentConnection, + err, + ) + } + defer conn.Close() + + // Direct table access: squix explore
[-l N] + if len(args) > 0 { + a.openTableResults(conn, args[0], flags.limit) + return + } + + if flags.oneline { + a.printTablesAndViews(conn, !flags.viewsOnly, !flags.tablesOnly) + return + } + + a.runExplorerLoop(conn, !flags.viewsOnly, !flags.tablesOnly) +} + +// runExplorerLoop opens the interactive schema explorer and re-opens it after +// each drill-down action until the user quits. +func (a *App) runExplorerLoop(conn db.DatabaseConnection, showTables, showViews bool) { + cursor := 0 + for { + done := make(chan struct{}) + go spinner.CircleWaitWithTimer(done) + + var tables, views []string + var err error + + if showTables { + tables, err = conn.GetTables() + if err != nil { + printError("Could not list tables: %v", err) } - break + } + if showViews { + views, err = conn.GetViews() + if err != nil { + printError("Could not list views: %v", err) + } + } + + spinner.Stop(done) + + model, err := explorer.Render( + conn, + tables, + views, + cursor, + a.config.UIVisibility, + a.config.KeyMap, + ) + if err != nil { + printError("Error rendering explorer: %v", err) + } + + cursor = model.Cursor() + + switch model.PendingAction() { + case explorer.ActionSelectAll: + a.openTableResults(conn, model.SelectedTable(), 0) + case explorer.ActionColumns: + a.openColumnsResults(conn, model.SelectedTable()) + default: + return } } +} - if a.config.CurrentConnection == "" { - printError("No active connection. Use 'squix switch ' or 'squix init' first") +// handleReplExplore handles the explore command inside the REPL. With an +// argument it opens the table directly; otherwise it opens the explorer. +func (a *App) handleReplExplore(conn db.DatabaseConnection, args []string) { + if len(args) > 0 { + a.openTableResults(conn, args[0], 0) + return } + a.runExplorerLoop(conn, true, true) +} - conn := config.FromConnectionYaml(a.config.Connections[a.config.CurrentConnection]) +func (a *App) printTablesAndViews(conn db.DatabaseConnection, showTables, showViews bool) { + if showTables { + tables, err := conn.GetTables() + if err != nil { + printError("Could not list tables: %v", err) + } + for _, t := range tables { + fmt.Println(strings.TrimSpace(t)) + } + } - if err := conn.Open(); err != nil { - printError("Could not open connection: %v", err) + if showViews { + views, err := conn.GetViews() + if err != nil { + printError("Could not list views: %v", err) + } + for _, v := range views { + fmt.Println(strings.TrimSpace(v)) + } } - defer conn.Close() +} - sql := fmt.Sprintf("SELECT * FROM %s", tableName) +func (a *App) openTableResults(conn db.DatabaseConnection, tableName string, limit int) { + sqlQuery := fmt.Sprintf("SELECT * FROM %s", tableName) + if limit <= 0 { + limit = a.config.DefaultRowLimit + } if limit > 0 { - sql = conn.ApplyRowLimit(sql, limit) + sqlQuery = conn.ApplyRowLimit(sqlQuery, limit) + } + + query := db.Query{ + Name: tableName, + SQL: sqlQuery, + TableName: tableName, + Id: -1, + } + + if metadata, err := conn.GetTableMetadata(tableName); err == nil && metadata != nil { + query.PrimaryKeys = metadata.PrimaryKeys } var onRerun func(string) error @@ -74,118 +208,99 @@ func (a *App) handleExplore() { OnRerun: onRerun, }) } - if err := run.ExecuteSelect(sql, tableName, run.ExecutionParams{ - Query: db.Query{Name: tableName, SQL: sql}, - Connection: conn, - Config: a.config, - OnRerun: onRerun, + + if err := run.ExecuteSelect(sqlQuery, tableName, run.ExecutionParams{ + Query: query, + Connection: conn, + Config: a.config, + SaveCallback: a.saveQueryFromTable, + OnRerun: onRerun, }); err != nil { printError("%v", err) } } -func (a *App) listTablesAndViews() { - if a.config.CurrentConnection == "" { - printError( - "No active connection. Use 'squix switch ' or 'squix init' first", - ) - } - - conn := config.FromConnectionYaml( - a.config.Connections[a.config.CurrentConnection], - ) - - if err := conn.Open(); err != nil { - printError( - "Could not open connection to %s: %v", - a.config.CurrentConnection, - err, - ) - } - defer conn.Close() - - tables, err := conn.GetTables() +func (a *App) openColumnsResults(conn db.DatabaseConnection, name string) { + metadata, err := conn.GetTableMetadata(name) if err != nil { - printError("Could not list tables: %v", err) + fmt.Println(styles.Error.Render(fmt.Sprintf("Could not load columns for %s: %v", name, err))) + return } - - views, err := conn.GetViews() - if err != nil { - printError("Could not list views: %v", err) + if metadata == nil { + fmt.Println(styles.Error.Render(fmt.Sprintf("No metadata available for %s", name))) + return } - if len(tables) > 0 { - fmt.Printf("%s tables %s\n", styles.Title.Render("◆"), styles.Faint.Render(fmt.Sprintf("(%d)", len(tables)))) - a.formatTableList(tables) - fmt.Println() - } + columns := []string{"column", "type", "pk", "fk", "unique"} - if len(views) > 0 { - fmt.Printf("%s views %s\n", styles.Title.Render("◆"), styles.Faint.Render(fmt.Sprintf("(%d)", len(views)))) - a.formatTableList(views) - fmt.Println() + pkSet := make(map[string]bool, len(metadata.PrimaryKeys)) + for _, pk := range metadata.PrimaryKeys { + pkSet[pk] = true } -} - -func (a *App) formatTableList(items []string) { - if len(items) == 0 { - return + uniqueSet := make(map[string]bool, len(metadata.UniqueConstraints)) + for _, uc := range metadata.UniqueConstraints { + uniqueSet[uc] = true + } + fkMap := make(map[string]string, len(metadata.ForeignKeys)) + for _, fk := range metadata.ForeignKeys { + fkMap[strings.ToLower(fk.Column)] = fk.ReferencedTable + "." + fk.ReferencedColumn } - // Filter out any empty or whitespace-only items - filtered := make([]string, 0, len(items)) - for _, item := range items { - trimmed := strings.TrimSpace(item) - if trimmed != "" { - filtered = append(filtered, trimmed) + data := make([][]string, 0, len(metadata.Columns)) + for i, col := range metadata.Columns { + typ := "" + if i < len(metadata.ColumnTypes) { + typ = metadata.ColumnTypes[i] } - } - items = filtered - maxLen := 0 - for _, item := range items { - if len(item) > maxLen { - maxLen = len(item) + pkMark := "" + if pkSet[col] { + pkMark = "✓" } - } - // Add padding for spacing - columnWidth := maxLen + 2 + fkRef := fkMap[strings.ToLower(col)] - // Simple terminal width detection (default to 120 if we can't detect) - termWidth := 120 + uniqueMark := "" + if uniqueSet[col] { + uniqueMark = "✓" + } - numColumns := termWidth / columnWidth - if numColumns < 1 { - numColumns = 1 + data = append(data, []string{col, typ, pkMark, fkRef, uniqueMark}) + } + if len(data) == 0 { + fmt.Println(styles.Faint.Render(fmt.Sprintf("No columns found for %s", name))) + return } - for i, item := range items { - fmt.Printf("%-*s", columnWidth, item) + vis := a.config.UIVisibility + vis.QuerySQL = false - // New line after filling a row - if (i+1)%numColumns == 0 { - fmt.Println() - } - } + q := db.Query{Name: fmt.Sprintf("%s columns", name)} - // New line if last row wasn't complete - if len(items)%numColumns != 0 { - fmt.Println() + if _, err := table.Render( + columns, + nil, + data, + 0, + conn, + "", + "", + q, + a.config.DefaultColumnWidth, + vis, + a.config.KeyMap, + nil, + ); err != nil { + printError("Error rendering columns: %v", err) } } var supportedFileTypes = map[string]bool{ ".csv": true, - // ".json": true, // Future support } -func getSupportedExtension(arg string) (string, bool) { - ext := strings.ToLower(filepath.Ext(arg)) - if supported, ok := supportedFileTypes[ext]; ok && supported { - return ext, true - } - return "", false +func isSupportedFile(arg string) bool { + return supportedFileTypes[strings.ToLower(filepath.Ext(arg))] } func parseCSV(path string) (columns []string, data [][]string, err error) { @@ -199,13 +314,11 @@ func parseCSV(path string) (columns []string, data [][]string, err error) { reader.LazyQuotes = true reader.TrimLeadingSpace = true - // Read header row columns, err = reader.Read() if err != nil { return nil, nil, fmt.Errorf("could not read CSV header: %w", err) } - // Read data rows data, err = reader.ReadAll() if err != nil { return nil, nil, fmt.Errorf("could not read CSV data: %w", err) @@ -214,27 +327,26 @@ func parseCSV(path string) (columns []string, data [][]string, err error) { return columns, data, nil } -func (a *App) handleExploreFile(path string, ext string) error { +func (a *App) handleExploreFile(path string) error { columns, data, err := parseCSV(path) if err != nil { return err } - // Render table with nil connection (read-only mode) columnTypes := make([]string, len(columns)) _, err = table.Render( columns, columnTypes, data, - 0, // elapsed time - nil, // no database connection - "", // no table name - "", // no primary key - db.Query{}, // empty query + 0, + nil, + "", + "", + db.Query{}, a.config.DefaultColumnWidth, a.config.UIVisibility, - a.config.KeyMap, // use config keybindings if available - nil, // no save callback + a.config.KeyMap, + nil, ) if err != nil { return fmt.Errorf("could not render table: %w", err) diff --git a/cmd/squix/help.go b/cmd/squix/help.go index f054e3a..b29b493 100644 --- a/cmd/squix/help.go +++ b/cmd/squix/help.go @@ -84,19 +84,13 @@ func (a *App) PrintGeneralHelp() { ), ) fmt.Println( - " tables " + styles.Faint.Render("List or query database tables"), - ) - fmt.Println( - " explore " + styles.Faint.Render("Explore database schema"), + " explore " + styles.Faint.Render( + "Explore database schema interactively or query a table", + ), ) fmt.Println( " list " + styles.Faint.Render("List connections or queries"), ) - fmt.Println( - " info " + styles.Faint.Render( - "Show tables or views in current connection", - ), - ) fmt.Println( " edit " + styles.Faint.Render( "Edit queries in your editor", @@ -273,9 +267,7 @@ func (a *App) PrintCommandHelp() { fmt.Println(" squix add [query]") fmt.Println() section("Description") - fmt.Println( - fmt.Sprintf(" - If [query] is omitted, squix opens $EDITOR (default: %s) so you", editor.GetEditorCommand()), - ) + fmt.Printf(" - If [query] is omitted, squix opens $EDITOR (default: %s) so you\n", editor.GetEditorCommand()) fmt.Println(" can write the query interactively.") fmt.Println(" - Each query gets a numeric ID as well as a name.") fmt.Println(" - Requires an active connection (use 'squix switch').") @@ -429,37 +421,6 @@ func (a *App) PrintCommandHelp() { fmt.Println(" squix list queries --oneline # list each query in one separate line") fmt.Println(" squix list connections") - case "tables": - section("Command: tables") - fmt.Println( - styles.Faint.Render( - "List all tables in the current database or query a specific table.", - ), - ) - fmt.Println() - section("Usage") - fmt.Println(" squix tables [table-name] [--oneline | -o]") - fmt.Println() - section("Description") - fmt.Println( - " Without arguments, lists all tables in the current database connection.", - ) - fmt.Println( - " With a table name, executes 'SELECT * FROM
' and displays results", - ) - fmt.Println(" in an interactive table view.") - fmt.Println() - fmt.Println( - " --oneline, -o " + styles.Faint.Render( - "Display table names one per line (useful for scripting)", - ), - ) - fmt.Println() - section("Examples") - fmt.Println(" squix tables # list all tables") - fmt.Println(" squix tables users # query the users table") - fmt.Println(" squix tables --oneline # list tables in oneline format") - case "disconnect": section("Command: disconnect") fmt.Println( @@ -516,9 +477,7 @@ func (a *App) PrintCommandHelp() { fmt.Println(" squix config") fmt.Println() section("Description") - fmt.Println( - fmt.Sprintf(" Opens the configuration file (%s) in your editor.", configPathDisplay()), - ) + fmt.Printf(" Opens the configuration file (%s) in your editor.\n", configPathDisplay()) fmt.Println(" Allows you to edit connections, color schemes, and other settings.") fmt.Println() section("Examples") @@ -528,18 +487,32 @@ func (a *App) PrintCommandHelp() { section("Command: explore") fmt.Println( styles.Faint.Render( - "Explore your database schema and query tables interactively, or view CSV files.", + "Explore your database schema interactively, query tables, or view CSV files.", ), ) fmt.Println() section("Usage") fmt.Println(" squix explore") + fmt.Println(" squix explore [--oneline | -o] [--tables] [--views]") fmt.Println(" squix explore
[--limit | -l N]") fmt.Println(" squix explore ") fmt.Println() section("Description") fmt.Println( - " Without arguments, lists all tables and views in multi-column format.", + " Without arguments, opens an interactive schema explorer listing tables", + ) + fmt.Println(" and views in foldable sections.") + fmt.Println() + fmt.Println( + " --oneline, -o " + styles.Faint.Render( + "Print table/view names one per line (scriptable)", + ), + ) + fmt.Println( + " --tables " + styles.Faint.Render("Only show tables"), + ) + fmt.Println( + " --views " + styles.Faint.Render("Only show views"), ) fmt.Println( " With a table name, queries the table and shows results in an", @@ -555,8 +528,56 @@ func (a *App) PrintCommandHelp() { ), ) fmt.Println() + section("Explorer keybinds") + fmt.Println( + " j / k " + styles.Faint.Render("Navigate items"), + ) + fmt.Println( + " g / G " + styles.Faint.Render("Jump to top / bottom"), + ) + fmt.Println( + " Ctrl+u / Ctrl+d " + styles.Faint.Render("Page up / down"), + ) + fmt.Println( + " enter " + styles.Faint.Render("SELECT * from selected item"), + ) + fmt.Println( + " c " + styles.Faint.Render("Show columns metadata"), + ) + fmt.Println( + " r " + styles.Faint.Render("Show relationships tree"), + ) + fmt.Println( + " Tab " + styles.Faint.Render("Fold/unfold current section"), + ) + fmt.Println( + " / " + styles.Faint.Render("Search items"), + ) + fmt.Println( + " n / N " + styles.Faint.Render("Next/previous match"), + ) + fmt.Println( + " H " + styles.Faint.Render("Show keyboard shortcuts overlay"), + ) + fmt.Println( + " q / ctrl+c " + styles.Faint.Render("Quit"), + ) + fmt.Println() + fmt.Println( + styles.Faint.Render( + " Explorer keybinds use built-in defaults; add explorer_* entries", + ), + ) + fmt.Println( + styles.Faint.Render( + " under keybindings: in ~/.config/squix/config.yaml to override.", + ), + ) + fmt.Println() section("Examples") - fmt.Println(" squix explore # list all tables and views") + fmt.Println(" squix explore # open interactive schema explorer") + fmt.Println(" squix explore --oneline # print tables/views one per line") + fmt.Println(" squix explore --views # explorer with views only") fmt.Println(" squix explore employees # query employees table") fmt.Println(" squix explore orders -l 50 # query with 50 row limit") fmt.Println(" squix explore data.csv # view CSV file") @@ -635,38 +656,6 @@ func (a *App) PrintCommandHelp() { fmt.Println(" squix example my.db # create ./my.db") fmt.Println(" squix example --force # recreate ./example.db") - case "info": - section("Command: info") - fmt.Println( - styles.Faint.Render( - "Show all tables or views in the current database connection.", - ), - ) - fmt.Println() - section("Usage") - fmt.Println(" squix info ") - fmt.Println() - section("Description") - fmt.Println( - " tables " + styles.Faint.Render( - "List all tables in the current connection/schema.", - ), - ) - fmt.Println( - " views " + styles.Faint.Render( - "List all views in the current connection/schema.", - ), - ) - fmt.Println() - section("Columns displayed") - fmt.Println(" - schema (if supported by database)") - fmt.Println(" - name") - fmt.Println(" - owner (if supported by database)") - fmt.Println() - section("Examples") - fmt.Println(" squix info tables") - fmt.Println(" squix info views") - case "status": section("Command: status") fmt.Println(styles.Faint.Render("Show the current active connection.")) diff --git a/cmd/squix/info.go b/cmd/squix/info.go deleted file mode 100644 index 12beee3..0000000 --- a/cmd/squix/info.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/eduardofuncao/squix/internal/config" - "github.com/eduardofuncao/squix/internal/db" - "github.com/eduardofuncao/squix/internal/run" -) - -func (a *App) handleInfo() { - if len(os.Args) < 3 { - printError("Usage: squix info ") - } - - infoType := os.Args[2] - - if infoType != "tables" && infoType != "views" { - printError("Unknown info type: %s. Use 'tables' or 'views'", infoType) - } - - if a.config.CurrentConnection == "" { - printError("No active connection. Use 'squix switch ' or 'squix init' first") - } - - conn := config.FromConnectionYaml(a.config.Connections[a.config.CurrentConnection]) - - queryStr := conn.GetInfoSQL(infoType) - if queryStr == "" { - printError("Could not get SQL for info type: %s", infoType) - } - - if err := conn.Open(); err != nil { - printError("Could not open connection: %v", err) - } - defer conn.Close() - - var onRerun func(string) error - onRerun = func(sql string) error { - return run.ExecuteSelect(sql, "", run.ExecutionParams{ - Query: db.Query{Name: "", SQL: sql}, - Connection: conn, - Config: a.config, - OnRerun: onRerun, - }) - } - if err := run.ExecuteSelect(queryStr, fmt.Sprintf("info %s", infoType), run.ExecutionParams{ - Query: db.Query{Name: fmt.Sprintf("info %s", infoType), SQL: queryStr}, - Connection: conn, - Config: a.config, - OnRerun: onRerun, - }); err != nil { - printError("%v", err) - } -} diff --git a/cmd/squix/init.go b/cmd/squix/init.go index 27812b8..a5cd7aa 100644 --- a/cmd/squix/init.go +++ b/cmd/squix/init.go @@ -102,23 +102,23 @@ func parseInitArgs(args []string) (string, string, string, string) { for i := 0; i < len(args); i++ { arg := args[i] - switch { - case arg == "--name" || arg == "-n": + switch arg { + case "--name", "-n": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { name = args[i+1] i++ } - case arg == "--type" || arg == "-t": + case "--type", "-t": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { dbType = args[i+1] i++ } - case arg == "--conn-string" || arg == "--conn" || arg == "-c": + case "--conn-string", "--conn", "-c": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { connString = args[i+1] i++ } - case arg == "--schema" || arg == "-s": + case "--schema", "-s": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { schema = args[i+1] i++ diff --git a/cmd/squix/list.go b/cmd/squix/list.go index 038125a..ebabdcd 100644 --- a/cmd/squix/list.go +++ b/cmd/squix/list.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/parser" "github.com/eduardofuncao/squix/internal/styles" @@ -62,27 +63,32 @@ func (a *App) renderList(objectType string, flags listFlags) { return } for name, connection := range a.config.Connections { - marker := "◆" + var marker string if name == a.config.CurrentConnection { marker = styles.Success.Render("●") } else { marker = styles.Faint.Render("◆") } - fmt.Printf("%s %s %s\n", marker, styles.Title.Render(name), styles.Faint.Render(fmt.Sprintf("(%s)", connection.DBType))) + line := fmt.Sprintf("%s %s %s", marker, styles.Title.Render(name), styles.Faint.Render(fmt.Sprintf("(%s)", connection.DBType))) + // For {service}:{env} names, show the shared group key faintly. + if key := config.QueryGroupKey(name); key != name { + line += " " + styles.Faint.Render(key) + } + fmt.Println(line) } case "queries": if a.config.CurrentConnection == "" { printError("No active connection. Use 'squix switch ' or 'squix init' first") } - conn := a.config.Connections[a.config.CurrentConnection] - if len(conn.Queries) == 0 { + queries := a.config.QueriesFor(a.config.CurrentConnection) + if len(queries) == 0 { fmt.Println(styles.Faint.Render("No queries saved")) return } - queryList := make([]db.Query, 0, len(conn.Queries)) - for _, query := range conn.Queries { + queryList := make([]db.Query, 0, len(queries)) + for _, query := range queries { if flags.searchTerm == "" { queryList = append(queryList, query) continue diff --git a/cmd/squix/remove.go b/cmd/squix/remove.go index 18873f3..7f0ec8e 100644 --- a/cmd/squix/remove.go +++ b/cmd/squix/remove.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/styles" ) @@ -34,15 +35,14 @@ func (a *App) handleRemove() { } // Otherwise, remove query (original behavior) - conn := a.config.Connections[a.config.CurrentConnection] - queries := conn.Queries + queries := a.config.QueriesFor(a.config.CurrentConnection) query, exists := db.FindQueryWithSelector(queries, os.Args[2]) if !exists { printError("Query '%s' could not be found", os.Args[2]) } - delete(conn.Queries, query.Name) + delete(queries, query.Name) err := a.config.Save() if err != nil { @@ -53,15 +53,16 @@ func (a *App) handleRemove() { } func (a *App) removeConnection(connName string) { - conn, exists := a.config.Connections[connName] - if !exists { + if _, exists := a.config.Connections[connName]; !exists { printError("Connection '%s' does not exist", connName) return } - queryCount := len(conn.Queries) + key := config.GroupKey(connName) + queryCount := len(a.config.QueriesFor(connName)) + hasSiblings := a.config.GroupHasOtherMembers(key, connName) - if !a.confirmDeletion(connName, queryCount) { + if !a.confirmConnectionDeletion(connName, key, queryCount, hasSiblings) { fmt.Println(styles.Faint.Render("Aborted")) return } @@ -71,6 +72,14 @@ func (a *App) removeConnection(connName string) { } delete(a.config.Connections, connName) + // Drop the shared library only if no other connection still derives it. + if !hasSiblings { + delete(a.config.QueryGroups, key) + // Remove the on-disk file too (only place group files are deleted). + if err := os.Remove(config.GroupFile(key)); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "squix: could not remove %s: %v\n", config.GroupFile(key), err) + } + } err := a.config.Save() if err != nil { @@ -78,12 +87,22 @@ func (a *App) removeConnection(connName string) { return } - fmt.Println(styles.Success.Render(fmt.Sprintf("✓ Removed connection '%s' and %d queries", connName, queryCount))) + if hasSiblings { + fmt.Println(styles.Success.Render(fmt.Sprintf("✓ Removed connection '%s' (group '%s' kept: %d queries shared with other connections)", connName, key, queryCount))) + } else { + fmt.Println(styles.Success.Render(fmt.Sprintf("✓ Removed connection '%s' and %d queries", connName, queryCount))) + } } -func (a *App) confirmDeletion(connName string, queryCount int) bool { +func (a *App) confirmConnectionDeletion(connName, groupKey string, queryCount int, hasSiblings bool) bool { reader := bufio.NewReader(os.Stdin) - fmt.Print(styles.Error.Render(fmt.Sprintf("This will delete connection '%s' and its %d queries. Continue? [y/N]: ", connName, queryCount))) + var prompt string + if hasSiblings { + prompt = fmt.Sprintf("This will delete connection '%s'. Its query group '%s' (%d queries) stays shared with other connections. Continue? [y/N]: ", connName, groupKey, queryCount) + } else { + prompt = fmt.Sprintf("This will delete connection '%s' and its %d queries. Continue? [y/N]: ", connName, queryCount) + } + fmt.Print(styles.Error.Render(prompt)) response, err := reader.ReadString('\n') if err != nil { diff --git a/cmd/squix/run.go b/cmd/squix/run.go index 41efaa8..f198c6c 100644 --- a/cmd/squix/run.go +++ b/cmd/squix/run.go @@ -17,7 +17,7 @@ func (a *App) handleRun() { printError("No active connection. Use 'squix switch ' or 'squix init' first") } - conn := config.FromConnectionYaml(a.config.Connections[a.config.CurrentConnection]) + conn := a.config.LiveConnection(a.config.CurrentConnection) args := os.Args[2:] // Check for empty args → open editor for new query (CLI-only feature) @@ -37,7 +37,7 @@ func (a *App) handleRun() { func (a *App) runFromArgs(args []string, conn db.DatabaseConnection) error { flags := parseRunFlagsFrom(args) - resolved, err := run.ResolveQuery(flags, a.config, a.config.CurrentConnection, conn) + resolved, err := run.ResolveQuery(flags, a.config.CurrentConnection, conn) if err != nil { return err } @@ -227,11 +227,6 @@ func (a *App) createNewQueryOrEdit() db.Query { printError("Empty SQL, cancelled") } - // Persist before execution so the SQL survives a syntax failure or a TUI quit-without-save. - if serr := config.SaveLastQuery(editedSQL); serr != nil { - fmt.Fprintf(os.Stderr, "warning: could not save last-query: %v\n", serr) - } - return db.Query{Name: "", SQL: editedSQL, Id: -1} } @@ -253,8 +248,7 @@ func (a *App) saveIfNeeded(resolved run.ResolvedQuery) { return } - // Save the query and update last query - if err := a.config.SaveQueryAndLast(a.config.CurrentConnection, resolved.Query, true); err != nil { + if _, err := a.config.SaveQueryToConnection(a.config.CurrentConnection, resolved.Query); err != nil { printError("Failed to save query: %v", err) } } @@ -262,6 +256,12 @@ func (a *App) saveIfNeeded(resolved run.ResolvedQuery) { type executorFunc func(run.ExecutionParams) error func (a *App) executeQueryWithParamsInternal(query db.Query, conn db.DatabaseConnection, paramFlags, positionalArgs map[string]string, executor executorFunc, noInteractive bool, flags run.Flags) error { + // Record the last-run SQL before execution so `squix run --last` and the + // editor pre-fill both read it. Template form (with :param placeholders) so + // --last re-prompts for params. Survives syntax failures and TUI quits. + if serr := config.SaveLastQuery(query.SQL); serr != nil { + fmt.Fprintf(os.Stderr, "warning: could not save last-query: %v\n", serr) + } sql, args, displaySQL, err := a.processParameters(query.SQL, conn, paramFlags, positionalArgs, noInteractive) if err != nil { return err @@ -433,10 +433,5 @@ func (a *App) saveQueryFromTable(query db.Query) (db.Query, error) { return db.Query{}, err } - // Update last query - if err := a.config.UpdateLastQuery(connName, savedQuery); err != nil { - return db.Query{}, err - } - return savedQuery, nil } diff --git a/cmd/squix/shell.go b/cmd/squix/shell.go index b378800..08b4322 100644 --- a/cmd/squix/shell.go +++ b/cmd/squix/shell.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/chzyer/readline" - "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/params" "github.com/eduardofuncao/squix/internal/run" @@ -18,7 +17,7 @@ func (a *App) handleShell() { printError("No active connection. Use 'squix switch ' or 'squix init' first") } - conn := config.FromConnectionYaml(a.config.Connections[a.config.CurrentConnection]) + conn := a.config.LiveConnection(a.config.CurrentConnection) var err error if err = conn.Open(); err != nil { printError("Could not open connection: %v", err) @@ -133,8 +132,8 @@ func (a *App) executeReplLine(rl *readline.Instance, conn db.DatabaseConnection, case "list", "ls", "\\l": a.handleListWithArgs(tokens[1:]) return false - case "tables", "\\dt": - a.handleReplTables(conn, tokens[1:]) + case "explore", "tables", "\\dt": + a.handleReplExplore(conn, tokens[1:]) return false } } @@ -190,7 +189,7 @@ func parseInput(input string) []string { func (a *App) runFromArgsOpenConn(args []string, conn db.DatabaseConnection) error { flags := parseRunFlagsFrom(args) - resolved, err := run.ResolveQuery(flags, a.config, a.config.CurrentConnection, conn) + resolved, err := run.ResolveQuery(flags, a.config.CurrentConnection, conn) if err != nil { return err } @@ -229,7 +228,7 @@ func shellHelpText() string { sb.WriteString(" help, \\h Show this help\n") sb.WriteString(" status Show connection info\n") sb.WriteString(" list, ls, \\l List saved queries or connections\n") - sb.WriteString(" tables, \\dt [name] List tables or view table data\n") + sb.WriteString(" explore, \\dt [name] Open schema explorer or view table data\n") sb.WriteString("\n") sb.WriteString(styles.Title.Render("Query Execution")) sb.WriteString("\n") diff --git a/cmd/squix/status.go b/cmd/squix/status.go index f989a8c..225db20 100644 --- a/cmd/squix/status.go +++ b/cmd/squix/status.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/eduardofuncao/squix/internal/config" "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/spinner" "github.com/eduardofuncao/squix/internal/styles" @@ -17,10 +16,7 @@ func (a *App) printConnStatus(conn db.DatabaseConnection) { connInfo += fmt.Sprintf(" (schema: %s)", currConn.Schema) } - queryCount := 0 - if currConn.Queries != nil { - queryCount = len(currConn.Queries) - } + queryCount := len(a.config.QueriesFor(a.config.CurrentConnection)) interactive := spinner.Interactive() if interactive { @@ -41,15 +37,15 @@ func (a *App) printConnStatus(conn db.DatabaseConnection) { var isReachable bool select { case isReachable = <-reachable: - close(done) + spinner.Stop(done) case <-time.After(5 * time.Second): - close(done) + spinner.Stop(done) isReachable = false } if interactive { - // Clear the spinner line + the provisional header above it. - fmt.Print("\r\033[2K") + // Clear the header line above the spinner (Stop already cleared the + // spinner line itself). fmt.Print("\033[1A") fmt.Print("\r\033[2K") } @@ -71,7 +67,7 @@ func (a *App) handleStatus() { return } - conn := config.FromConnectionYaml(a.config.Connections[a.config.CurrentConnection]) + conn := a.config.LiveConnection(a.config.CurrentConnection) conn.Open() defer conn.Close() diff --git a/cmd/squix/tables.go b/cmd/squix/tables.go deleted file mode 100644 index 5ce7242..0000000 --- a/cmd/squix/tables.go +++ /dev/null @@ -1,344 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/eduardofuncao/squix/internal/config" - "github.com/eduardofuncao/squix/internal/db" - "github.com/eduardofuncao/squix/internal/run" - "github.com/eduardofuncao/squix/internal/spinner" - "github.com/eduardofuncao/squix/internal/table" -) - -type tablesFlags struct { - oneline bool - hideQueryName bool - hideQuerySQL bool -} - -func parseTablesFlags() (tablesFlags, []string) { - flags := tablesFlags{} - remainingArgs := []string{} - args := os.Args[2:] - - for _, arg := range args { - switch arg { - case "--oneline", "-o": - flags.oneline = true - case "--hide-query-name": - flags.hideQueryName = true - case "--hide-query-sql": - flags.hideQuerySQL = true - default: - if !strings.HasPrefix(arg, "-") { - remainingArgs = append(remainingArgs, arg) - } - } - } - - return flags, remainingArgs -} - -func (a *App) handleTables() { - if a.config.CurrentConnection == "" { - printError( - "No active connection. Use 'squix switch ' or 'squix init' first", - ) - } - - flags, args := parseTablesFlags() - conn := config.FromConnectionYaml( - a.config.Connections[a.config.CurrentConnection], - ) - - if err := conn.Open(); err != nil { - printError( - "Could not open connection to %s/%s: %s", - conn.GetDbType(), - conn.GetName(), - err, - ) - } - defer conn.Close() - - // If a table name is provided, run SELECT * FROM table - if len(args) > 0 { - tableName := args[0] - - // Create a temporary query object with table metadata - query := db.Query{ - Name: tableName, - SQL: fmt.Sprintf("SELECT * FROM %s", tableName), - TableName: tableName, - Id: -1, - } - - // Try to get primary key from table metadata - if metadata, err := conn.GetTableMetadata( - tableName, - ); err == nil && - metadata != nil { - query.PrimaryKeys = metadata.PrimaryKeys - } - - if err := run.ExecuteSelect( - query.SQL, - query.Name, - run.ExecutionParams{ - Query: query, - Connection: conn, - Config: a.config, - SaveCallback: a.saveQueryFromTable, - OnRerun: func(editedSQL string) error { - // Re-run query if edited - editedQuery := db.Query{ - Name: tableName, - SQL: editedSQL, - TableName: tableName, - Id: -1, - } - return run.ExecuteSelect( - editedSQL, - tableName, - run.ExecutionParams{ - Query: editedQuery, - Connection: conn, - Config: a.config, - }, - ) - }, - }, - ); err != nil { - printError("%v", err) - } - return - } - - // Display tables list using GetInfoSQL - queryStr := conn.GetInfoSQL("tables") - if queryStr == "" { - printError("Could not get tables SQL for this database type") - } - - // Extract just the name column by wrapping in a subquery - nameOnlyQuery := fmt.Sprintf( - "SELECT name FROM (%s) AS tables_info ORDER BY name", - queryStr, - ) - - if flags.oneline { - // For oneline mode, execute query and print just the table names - rows, err := conn.ExecQuery(nameOnlyQuery) - if err != nil { - printError("Could not retrieve tables: %v", err) - } - defer rows.Close() - - for rows.Next() { - var tableName string - if err := rows.Scan(&tableName); err != nil { - printError("Could not scan row: %v", err) - } - fmt.Println(tableName) - } - - if err := rows.Err(); err != nil { - printError("Error iterating tables: %v", err) - } - } else { - // For normal mode, use the interactive table viewer with name-only query - a.showTablesInteractive(conn, nameOnlyQuery, flags.hideQueryName, flags.hideQuerySQL) - } -} - -func (a *App) handleReplTables(conn db.DatabaseConnection, args []string) { - if len(args) > 0 { - tableName := args[0] - query := db.Query{ - Name: tableName, - SQL: fmt.Sprintf("SELECT * FROM %s", tableName), - TableName: tableName, - Id: -1, - } - - if metadata, err := conn.GetTableMetadata(tableName); err == nil && metadata != nil { - query.PrimaryKeys = metadata.PrimaryKeys - } - - if err := run.ExecuteSelect( - query.SQL, - query.Name, - run.ExecutionParams{ - Query: query, - Connection: conn, - Config: a.config, - SaveCallback: a.saveQueryFromTable, - OnRerun: func(editedSQL string) error { - editedQuery := db.Query{ - Name: tableName, - SQL: editedSQL, - TableName: tableName, - Id: -1, - } - return run.ExecuteSelect( - editedSQL, - tableName, - run.ExecutionParams{ - Query: editedQuery, - Connection: conn, - Config: a.config, - }, - ) - }, - }, - ); err != nil { - printError("%v", err) - } - return - } - - // No args — list tables in interactive viewer - queryStr := conn.GetInfoSQL("tables") - if queryStr == "" { - printError("Could not get tables SQL for this database type") - } - - nameOnlyQuery := fmt.Sprintf( - "SELECT name FROM (%s) AS tables_info ORDER BY name", - queryStr, - ) - - a.showTablesInteractive(conn, nameOnlyQuery, false, false) -} - -func (a *App) showTablesInteractive( - conn db.DatabaseConnection, - queryStr string, - hideName bool, - hideSQL bool, -) { - for { - start := time.Now() - done := make(chan struct{}) - go spinner.CircleWaitWithTimer(done) - - rows, err := conn.ExecQuery(queryStr) - if err != nil { - done <- struct{}{} - fmt.Print("\r\033[2K") - printError("Could not retrieve tables: %v", err) - } - - columns, data, err := db.FormatTableData(rows) - if err != nil { - done <- struct{}{} - fmt.Print("\r\033[2K") - printError("Could not format table data: %v", err) - } - - done <- struct{}{} - fmt.Print("\r\033[2K") - elapsed := time.Since(start) - - if len(data) == 0 { - fmt.Println("No tables found") - return - } - - // The list query selects a single "name" column; relabel it "table". - if len(columns) > 0 && columns[0] == "name" { - columns[0] = "table" - } - - q := db.Query{ - Name: "tables", - SQL: queryStr, - } - - vis := a.config.UIVisibility - if hideName { - vis.QueryName = false - } - if hideSQL { - vis.QuerySQL = false - } - - model, err := table.RenderTablesList( - columns, - data, - elapsed, - conn, - q, - a.config.DefaultColumnWidth, - vis, - a.config.KeyMap, - ) - if err != nil { - printError("Error rendering tables: %v", err) - } - - // Check if user selected a table - selectedTable := model.GetSelectedTableName() - if selectedTable != "" { - // User pressed Enter on a table - query it - query := db.Query{ - Name: selectedTable, - SQL: fmt.Sprintf("SELECT * FROM %s", selectedTable), - TableName: selectedTable, - Id: -1, - } - - // Try to get primary key from table metadata - if metadata, err := conn.GetTableMetadata( - selectedTable, - ); err == nil && - metadata != nil { - query.PrimaryKeys = metadata.PrimaryKeys - } - - if err := run.ExecuteSelect( - query.SQL, - query.Name, - run.ExecutionParams{ - Query: query, - Connection: conn, - Config: a.config, - SaveCallback: a.saveQueryFromTable, - OnRerun: func(editedSQL string) error { - editedQuery := db.Query{ - Name: selectedTable, - SQL: editedSQL, - TableName: selectedTable, - Id: -1, - } - return run.ExecuteSelect( - editedSQL, - selectedTable, - run.ExecutionParams{ - Query: editedQuery, - Connection: conn, - Config: a.config, - }, - ) - }, - }, - ); err != nil { - printError("%v", err) - } - // After returning from table view, go back to tables list - continue - } - - // Check if user wants to re-run (edited the query) - if model.ShouldRerunQuery() { - queryStr = model.GetEditedQuery().SQL - continue - } - - // User quit without selecting anything - break - } -} diff --git a/docs/commands.md b/docs/commands.md index 8b0e4c0..2df1d71 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -30,11 +30,11 @@ | Command | Description | Example | |---------|-------------|---------| -| `explore` | List all tables and views in multi-column format | `squix explore` | +| `explore` | Open interactive schema explorer (tables + views) | `squix explore` | +| `explore --oneline [--tables] [--views]` | Print table/view names one per line | `squix explore --oneline` | | `explore
[-l N]` | Query a table with optional row limit | `squix explore employees --limit 100` | | `explore ` | View a local CSV file in the results view (read-only) | `squix explore sales.csv` | -| `explain
[-d N] [-c]` | Visualize foreign key relationships | `squix explain employees --depth 2` | -| `tables` | List all tables in using the results view, access with Enter| `squix tables` | +| `explain
[-d N] [-v]` | Visualize foreign key relationships | `squix explain employees --depth 2` | | `example [path] [--force]` | Create a sample office-themed SQLite database to try squix | `squix example` | ## Configuration @@ -42,7 +42,7 @@ | Command | Description | Example | |---------|-------------|---------| | `config` | Edit main configuration file | `squix config` | -| `edit` | Edit all queries for current connection | `squix edit` | +| `edit` | Edit all queries for current connection's library (shared across `{service}:{env}` siblings) | `squix edit` | | `edit ` | Edit a single named query | `squix edit 3` | | `remove --connection ` | Remove a db connection | `squix remove --conection dev4`` | | `help [command]` | Show help information | `squix help run` | diff --git a/docs/completion.md b/docs/completion.md index f0f97fe..44c3add 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -48,11 +48,10 @@ After installation, press TAB to autocomplete: squix [TAB] # List all commands squix run [TAB] # List queries from current connection squix switch [TAB] # List connection names -squix info [TAB] # List: table, view squix list [TAB] # List: queries squix edit [TAB] # List queries to edit ``` -**Note:** Query completion only shows queries from your current connection. Use `squix switch ` to change connections. +**Note:** Query completion draws from your current connection's query library — shared across `{service}:{env}` siblings (e.g. `ecommerce:dev`, `ecommerce:prod` share one library). Use `squix switch ` to change connections. > Currently unreleased, build from source to make this available diff --git a/docs/configuration.md b/docs/configuration.md index 0bb530d..5983e11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,64 @@ Squix stores its configuration at `~/.config/squix/config.yaml`. +## Saved queries + +Saved queries live as plain SQL in `~/.config/squix/queries/`, one file per +**query group** at `queries/.sql`. Each file is valid SQL you can open, +edit, or run in any client; squix slices it into named queries with directives: + +```sql +-- @query list_orders +SELECT * FROM orders ORDER BY created_at DESC; + +-- @query editable_users +-- @table users +-- @pk id +SELECT id, email FROM users; +``` + +A block starts at a `-- @query ` line. Optional `-- @table ` and +`-- @pk [,]` lines right after carry editable-table metadata (otherwise +inferred from the SQL at run time). Everything until the next `-- @query` (or +EOF) is the query body. A `-- @query` inside a body only starts a new block when +preceded by a blank line, so mid-SQL occurrences stay part of the body. + +`squix edit` (no arguments) opens the whole group file in your editor; `squix +edit ` opens a single query. + +Query ids (shown by `squix list`, usable via `squix run -s `) are assigned by +position at load time and are not stored in the file. Prefer `squix run -s +` — names are the stable selector. + +## Shared query libraries + +By default a connection owns a private group keyed by its own name, so a single +connection behaves exactly as before. To share one library across several +environments of the same service, name connections using the `{service}:{env}` +convention: + +```yaml +current_connection: ecommerce:dev +connections: + ecommerce:dev: {db_type: postgres, conn_string: postgres://dev-host/db} + ecommerce:prod: {db_type: postgres, conn_string: postgres://prod-host/db} + analytics: {db_type: sqlite, conn_string: analytics.db} # no colon → private group "analytics" +``` + +`ecommerce:dev`, `ecommerce:stg`, and `ecommerce:prod` are distinct connections +(each keeps its own `conn_string` and `schema`) but read and write +the **same** `queries/ecommerce.sql`. A query added on one is immediately visible +on the others. `analytics` (no colon) gets its own `queries/analytics.sql`. + +> **Migration:** if you are upgrading from an older squix version that stored +> queries inline under each connection in `config.yaml`, they are lifted into +> `queries/.sql` automatically on the first run (one-way). Back up +> `config.yaml` before the first run if you may need to downgrade. On a collision +> (two sibling envs each carrying different inline queries), the +> alphabetically-first connection wins and the others are dropped with a stderr +> warning so you can merge manually. Connection details stay in +> `config.yaml`. + ## Row Limit `default_row_limit: 1000` All queries are automatically limited to prevent fetching massive result sets. Configure via `default_row_limit` in config or use explicit `LIMIT` in your SQL queries. @@ -42,7 +100,7 @@ ui_visibility: ## Keybindings `keybindings` -Customize keyboard shortcuts in the table view. Each action accepts a single key or a list of keys: +Customize keyboard shortcuts in the table view and the schema explorer. Each action accepts a single key or a list of keys: ```yaml keybindings: diff --git a/docs/features.md b/docs/features.md index aa073a8..3baa704 100644 --- a/docs/features.md +++ b/docs/features.md @@ -38,6 +38,8 @@ squix run 2 squix run emp_by_salary --edit ``` +> **Shared libraries:** Connections named `{service}:{env}` (e.g. `ecommerce:dev`, `ecommerce:prod`) share one query library — a query added on one is visible on the others. See [Shared query libraries](configuration.md#shared-query-libraries). + image ## TUI Table Viewer @@ -77,18 +79,21 @@ squix status Explore your database schema and visualize relationships between tables. ```bash -# List all tables and views in multi-column format +# Open the interactive schema explorer (foldable tables/views sections) +# Keybinds: enter = SELECT *, c = columns, r = relationships, Tab = fold, +# / = search items, H = help squix explore +# Scriptable listing: print table/view names one per line +squix explore --oneline +squix explore --oneline --views # views only + # Query a table directly squix explore employees --limit 100 # View a local CSV file in the results view (read-only) squix explore sales.csv -# Open tables in the results view, use Enter to query everything in the table -squix tables - # Visualize foreign key relationships squix explain employees squix explain employees --depth 2 # Show relationships 2 levels deep diff --git a/docs/keybindings.md b/docs/keybindings.md index 9bb08bb..895c190 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -62,6 +62,32 @@ Press `Enter` on any cell to open a detailed view that shows the full cell conte Press `H` to open a categorized keyboard shortcuts reference. The overlay is context-aware — it adjusts the "Actions" section based on the current view (tables list, query without primary key, etc.). Press `H`, `q`, or `Esc` to close. +## Schema Explorer + +`squix explore` opens an interactive schema explorer with foldable Tables and Views sections (expanded by default). + +| Key | Action | +|-----|--------| +| `j`, `↓`, `k`, `↑` | Move selection | +| `g` / `G` | Jump to top / bottom | +| `Ctrl+u`, `Ctrl+d` | Page up / down | +| `enter` | Run `SELECT *` on selected table/view | +| `c` | Show columns metadata in the results view | +| `r` | Show relationships tree (scrollable panel) | +| `Tab` | Fold/unfold current section | +| `/` | Search items | +| `n` / `N` | Next/previous match | +| `H` | Show keyboard shortcuts overlay | +| `?` | Toggle keybindings help in footer | +| `q`, `ctrl+c` | Quit | + +**In the relationships panel:** + +| Key | Action | +|-----|--------| +| `j`, `↓`, `k`, `↑` | Scroll (`move_down` / `move_up`) | +| `q`, `ctrl+c` | Close panel (`quit`) | + ## Configurable Keybindings All keybindings can be customized in `~/.config/squix/config.yaml` under the `keybindings` key. Each action accepts a single key or a list of keys. @@ -82,6 +108,7 @@ Keybindings are resolved per **mode** — different views can bind the same key | `normal` | Main table view | | `detail` | Cell detail view (opened with `Enter`) | | `help` | Help overlay | +| `explorer` | Schema explorer list view | Unknown action names print a warning at startup. Conflicting keys within the same mode also print a warning. @@ -125,3 +152,7 @@ Unknown action names print a warning at startup. Conflicting keys within the sam | `detail_scroll_down` | `j`, `down` | Scroll detail view down | | `detail_open` | `o` | Open value in editor (detail view) | | `help_close` | `H`, `q`, `esc` | Close help overlay | +| `explorer_select` | `enter` | SELECT * from selected item (explorer) | +| `explorer_columns` | `c` | Show columns metadata (explorer) | +| `explorer_relations` | `r` | Show relationships tree (explorer) | +| `explorer_toggle_fold` | `tab` | Fold/unfold current section (explorer) | diff --git a/go.mod b/go.mod index 0a23020..f4397f2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( charm.land/lipgloss/v2 v2.0.4 github.com/ClickHouse/clickhouse-go/v2 v2.42.0 github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/x/ansi v0.11.7 github.com/chzyer/readline v1.5.1 github.com/duckdb/duckdb-go/v2 v2.10501.0 github.com/go-sql-driver/mysql v1.9.3 @@ -55,7 +56,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index 9969c3b..735daa9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,7 +5,9 @@ import ( "os" "path/filepath" "runtime" + "strings" + "github.com/eduardofuncao/squix/internal/db" "github.com/eduardofuncao/squix/internal/styles" "gopkg.in/yaml.v2" ) @@ -56,16 +58,17 @@ func (kc *KeybindingsConfig) UnmarshalYAML(unmarshal func(any) error) error { } type Config struct { - CurrentConnection string `yaml:"current_connection"` - Connections map[string]*ConnectionYAML `yaml:"connections"` - ColorScheme string `yaml:"color_scheme"` - CustomColorScheme *styles.ColorScheme `yaml:"custom_colors,omitempty"` - History History `yaml:"history"` - DefaultRowLimit int `yaml:"default_row_limit"` - DefaultColumnWidth int `yaml:"default_column_width"` - UIVisibility UIVisibility `yaml:"ui_visibility"` - Keybindings KeybindingsConfig `yaml:"keybindings,omitempty"` - KeyMap *KeyMap `yaml:"-"` + CurrentConnection string `yaml:"current_connection"` + Connections map[string]*ConnectionYAML `yaml:"connections"` + QueryGroups map[string]map[string]db.Query `yaml:"-"` + ColorScheme string `yaml:"color_scheme"` + CustomColorScheme *styles.ColorScheme `yaml:"custom_colors,omitempty"` + History History `yaml:"history"` + DefaultRowLimit int `yaml:"default_row_limit"` + DefaultColumnWidth int `yaml:"default_column_width"` + UIVisibility UIVisibility `yaml:"ui_visibility"` + Keybindings KeybindingsConfig `yaml:"keybindings,omitempty"` + KeyMap *KeyMap `yaml:"-"` } type History struct { @@ -90,6 +93,7 @@ func LoadConfig(path string) (*Config, error) { cfg := &Config{ CurrentConnection: "", Connections: make(map[string]*ConnectionYAML), + QueryGroups: make(map[string]map[string]db.Query), ColorScheme: "default", History: History{}, DefaultRowLimit: 1000, @@ -118,6 +122,18 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + if cfg.QueryGroups == nil { + cfg.QueryGroups = make(map[string]map[string]db.Query) + } + if err := cfg.loadQueryFiles(); err != nil { + return nil, err + } + if cfg.MigrateQueriesToFiles() { + if err := cfg.Save(); err != nil { + return nil, err + } + } + if cfg.DefaultColumnWidth == 0 { cfg.DefaultColumnWidth = 15 } @@ -145,14 +161,58 @@ func LoadConfig(path string) (*Config, error) { } func (c *Config) Save() error { - err := os.MkdirAll(CfgPath, 0755) - if err != nil { + if err := os.MkdirAll(CfgPath, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } + // Persist each query group to its .sql file. Disk must match memory, so a + // now-empty group (e.g. after its last query is removed) drops its file. + for key, queries := range c.QueryGroups { + if len(queries) == 0 { + if err := os.Remove(GroupFile(key)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove empty query group %s: %w", key, err) + } + continue + } + if err := WriteGroupFile(key, queries); err != nil { + return fmt.Errorf("failed to write query group %s: %w", key, err) + } + } + data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(CfgFile, data, 0644) } + +// loadQueryFiles reads queries/.sql into QueryGroups, assigning ids. +// A missing queries/ directory is not an error. +func (c *Config) loadQueryFiles() error { + entries, err := os.ReadDir(QueriesDir()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + key := strings.TrimSuffix(e.Name(), ".sql") + data, err := os.ReadFile(filepath.Join(QueriesDir(), e.Name())) + if err != nil { + fmt.Fprintf(os.Stderr, "squix: skipping unreadable query file %s: %v\n", e.Name(), err) + continue + } + queries, err := ParseGroupFile(data) + if err != nil { + fmt.Fprintf(os.Stderr, "squix: skipping malformed query file %s: %v\n", e.Name(), err) + continue + } + AssignIDs(queries) + c.QueryGroups[key] = queries + } + return nil +} diff --git a/internal/config/connectionYAML.go b/internal/config/connectionYAML.go index 37efd0a..817f8c4 100644 --- a/internal/config/connectionYAML.go +++ b/internal/config/connectionYAML.go @@ -8,12 +8,15 @@ import ( ) type ConnectionYAML struct { - Name string `yaml:"name"` - DBType string `yaml:"db_type"` - ConnString string `yaml:"conn_string"` - Schema string `yaml:"schema,omitempty"` - Queries map[string]db.Query `yaml:"queries"` - LastQuery db.Query `yaml:"last_query"` + Name string `yaml:"name"` + DBType string `yaml:"db_type"` + ConnString string `yaml:"conn_string"` + Schema string `yaml:"schema,omitempty"` + // Deprecated: legacy migration input only. Saved queries now live in + // queries/.sql files. Kept so the first load after upgrade can lift + // legacy per-connection queries; cleared by MigrateQueriesToFiles and never + // repopulated. + Queries map[string]db.Query `yaml:"queries,omitempty"` } func ToConnectionYAML(conn db.DatabaseConnection) *ConnectionYAML { @@ -22,8 +25,6 @@ func ToConnectionYAML(conn db.DatabaseConnection) *ConnectionYAML { DBType: conn.GetDbType(), ConnString: conn.GetConnString(), Schema: conn.GetSchema(), - Queries: conn.GetQueries(), - LastQuery: conn.GetLastQuery(), } } @@ -33,7 +34,5 @@ func FromConnectionYaml(yc *ConnectionYAML) db.DatabaseConnection { log.Fatalf("could not create connection from yaml for: %s/%s", yc.DBType, yc.Name) } conn.SetSchema(yc.Schema) - conn.SetQueries(yc.Queries) - conn.SetLastQuery(yc.LastQuery) return conn } diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 11fae7e..9f9b2af 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -30,14 +30,14 @@ const ( ActionUpdate Action = "update" ActionDeleteRow Action = "delete_row" - ActionEditSQL Action = "edit_sql" - ActionSaveQuery Action = "save_query" - ActionSearch Action = "search" - ActionSearchCol Action = "search_col" - ActionNextMatch Action = "next_match" - ActionPrevMatch Action = "prev_match" - ActionPrevColMatch Action = "prev_col_match" - ActionNextColMatch Action = "next_col_match" + ActionEditSQL Action = "edit_sql" + ActionSaveQuery Action = "save_query" + ActionSearch Action = "search" + ActionSearchCol Action = "search_col" + ActionNextMatch Action = "next_match" + ActionPrevMatch Action = "prev_match" + ActionPrevColMatch Action = "prev_col_match" + ActionNextColMatch Action = "next_col_match" ActionHelp Action = "help" ActionToggleFooter Action = "toggle_footer" @@ -50,6 +50,11 @@ const ( ActionDetailScrollUp Action = "detail_scroll_up" ActionDetailScrollDown Action = "detail_scroll_down" + ActionExplorerSelect Action = "explorer_select" + ActionExplorerColumns Action = "explorer_columns" + ActionExplorerRelations Action = "explorer_relations" + ActionExplorerToggleFold Action = "explorer_toggle_fold" + ActionHelpClose Action = "help_close" ) @@ -59,6 +64,8 @@ const ( ModeNormal Mode = iota ModeDetail ModeHelp + ModeExplorer + ModeExplorerRelations ) var modeActions = map[Mode][]Action{ @@ -80,6 +87,19 @@ var modeActions = map[Mode][]Action{ ModeHelp: { ActionHelpClose, }, + ModeExplorer: { + ActionMoveUp, ActionMoveDown, + ActionJumpFirstRow, ActionJumpLastRow, + ActionPageUp, ActionPageDown, + ActionSearch, ActionNextMatch, ActionPrevMatch, + ActionExplorerSelect, + ActionExplorerColumns, ActionExplorerRelations, ActionExplorerToggleFold, + ActionHelp, ActionToggleFooter, ActionQuit, + }, + ModeExplorerRelations: { + ActionMoveUp, ActionMoveDown, + ActionQuit, + }, } var DefaultKeybindings = map[Action][]string{ @@ -124,6 +144,11 @@ var DefaultKeybindings = map[Action][]string{ ActionDetailScrollUp: {"k", "up"}, ActionDetailScrollDown: {"j", "down"}, + ActionExplorerSelect: {"enter"}, + ActionExplorerColumns: {"c"}, + ActionExplorerRelations: {"r"}, + ActionExplorerToggleFold: {"tab"}, + ActionHelpClose: {"H", "q", "esc"}, } diff --git a/internal/config/library.go b/internal/config/library.go new file mode 100644 index 0000000..68d109c --- /dev/null +++ b/internal/config/library.go @@ -0,0 +1,114 @@ +package config + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/eduardofuncao/squix/internal/db" +) + +// QueryGroupKey derives the shared query-library key for a connection name. +func QueryGroupKey(name string) string { + i := strings.LastIndex(name, ":") + if i <= 0 { // no colon, or colon at position 0 (empty service) + return name + } + return name[:i] +} + +// QueriesFor returns the shared query-library map for connName +func (c *Config) QueriesFor(connName string) map[string]db.Query { + key := GroupKey(connName) + if c.QueryGroups[key] == nil { + c.QueryGroups[key] = make(map[string]db.Query) + } + return c.QueryGroups[key] +} + +// SetQueriesFor replaces the library contents for connName in place +func (c *Config) SetQueriesFor(connName string, m map[string]db.Query) { + queries := c.QueriesFor(connName) + for k := range queries { + delete(queries, k) + } + for k, v := range m { + queries[k] = v + } +} + +// LiveConnection builds a runtime DatabaseConnection for connName with its +// Queries populated from the shared library +func (c *Config) LiveConnection(connName string) db.DatabaseConnection { + conn := FromConnectionYaml(c.Connections[connName]) + conn.SetQueries(c.QueriesFor(connName)) + return conn +} + +// MigrateQueriesToFiles lifts legacy per-connection inline queries into +// per-group .sql files (one-way). Called once at load; returns true if anything +// was migrated so the caller can persist the cleaned config.yaml. Idempotent: +// no-op once a group's file exists / inline queries are cleared. +func (c *Config) MigrateQueriesToFiles() bool { + if c.QueryGroups == nil { + c.QueryGroups = make(map[string]map[string]db.Query) + } + + names := make([]string, 0, len(c.Connections)) + for name := range c.Connections { + names = append(names, name) + } + sort.Strings(names) + + migrated := false + for _, name := range names { + conn := c.Connections[name] + if conn == nil || len(conn.Queries) == 0 { + continue + } + key := GroupKey(name) + if existing, ok := c.QueryGroups[key]; ok && len(existing) > 0 { + // Group already populated (from its on-disk file or an earlier + // sibling): alphabetically-first wins, rest dropped with a warning. + dropped := make([]string, 0, len(conn.Queries)) + for q := range conn.Queries { + dropped = append(dropped, q) + } + sort.Strings(dropped) + fmt.Fprintf(os.Stderr, + "squix: query group %q already populated; dropping legacy queries from %q: %s\n", + key, name, strings.Join(dropped, ", ")) + conn.Queries = nil + continue + } + + queries := conn.Queries + AssignIDs(queries) + if err := WriteGroupFile(key, queries); err != nil { + // Keep inline queries so the next run retries; don't mark migrated. + fmt.Fprintf(os.Stderr, + "squix: failed to migrate queries for %q to %s: %v (keeping inline)\n", + name, GroupFile(key), err) + continue + } + c.QueryGroups[key] = queries + conn.Queries = nil + migrated = true + } + return migrated +} + +// GroupHasOtherMembers reports whether any connection other than exceptConnName +// derives the same group key. +func (c *Config) GroupHasOtherMembers(key, exceptConnName string) bool { + for name := range c.Connections { + if name == exceptConnName { + continue + } + if GroupKey(name) == key { + return true + } + } + return false +} diff --git a/internal/config/library_test.go b/internal/config/library_test.go new file mode 100644 index 0000000..00c9233 --- /dev/null +++ b/internal/config/library_test.go @@ -0,0 +1,225 @@ +package config + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/eduardofuncao/squix/internal/db" +) + +func TestQueryGroupKey(t *testing.T) { + cases := []struct{ name, want string }{ + {"ecommerce:dev", "ecommerce"}, + {"ecommerce:prod", "ecommerce"}, + {"ecommerce:stg", "ecommerce"}, + {"mydb", "mydb"}, + {"", ""}, + {":dev", ":dev"}, // empty service → whole name (private) + {"dev:", "dev"}, // empty env → service key "dev" + {"ecommerce:us:dev", "ecommerce:us"}, + } + for _, c := range cases { + got := QueryGroupKey(c.name) + if got != c.want { + t.Errorf("QueryGroupKey(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +func TestQueriesFor_SharedMutation(t *testing.T) { + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": {Name: "ecommerce:dev"}, + "ecommerce:prod": {Name: "ecommerce:prod"}, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + + dev := cfg.QueriesFor("ecommerce:dev") + dev["list_users"] = db.Query{Name: "list_users", Id: 1, SQL: "SELECT 1"} + + prod := cfg.QueriesFor("ecommerce:prod") + if _, ok := prod["list_users"]; !ok { + t.Fatal("prod does not see query added via dev") + } + // Same underlying map reference → mutations propagate. + if reflect.ValueOf(dev).Pointer() != reflect.ValueOf(prod).Pointer() { + t.Fatal("dev and prod queries maps are not the same reference") + } +} + +func TestQueriesFor_CreatesLibrary(t *testing.T) { + cfg := &Config{ + Connections: map[string]*ConnectionYAML{"analytics": {Name: "analytics"}}, + QueryGroups: map[string]map[string]db.Query{}, + } + q := cfg.QueriesFor("analytics") + if q == nil { + t.Fatal("QueriesFor returned nil") + } + q["x"] = db.Query{Name: "x"} + if cfg.QueryGroups["analytics"]["x"].Name != "x" { + t.Fatal("QueriesFor did not store the created library under the right key") + } +} + +func TestSetQueriesFor_PreservesReference(t *testing.T) { + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": {Name: "ecommerce:dev"}, + "ecommerce:prod": {Name: "ecommerce:prod"}, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + dev := cfg.QueriesFor("ecommerce:dev") + + cfg.SetQueriesFor("ecommerce:dev", map[string]db.Query{ + "q1": {Name: "q1", Id: 1}, + "q2": {Name: "q2", Id: 2}, + }) + + prod := cfg.QueriesFor("ecommerce:prod") + if len(prod) != 2 { + t.Fatalf("prod has %d queries after SetQueriesFor, want 2", len(prod)) + } + // Reference preserved: dev still points at the same (now repopulated) map. + if reflect.ValueOf(dev).Pointer() != reflect.ValueOf(prod).Pointer() { + t.Fatal("SetQueriesFor swapped the map reference instead of replacing in place") + } +} + +func TestMigrateQueriesToFiles_LegacyInline(t *testing.T) { + withTempCfg(t) + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": { + Name: "ecommerce:dev", + Queries: map[string]db.Query{"list_users": {Name: "list_users", SQL: "SELECT 1"}}, + }, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + if !cfg.MigrateQueriesToFiles() { + t.Fatal("expected migration to report it migrated something") + } + + if cfg.Connections["ecommerce:dev"].Queries != nil { + t.Error("legacy inline Queries not cleared after migration") + } + if got := cfg.QueryGroups["ecommerce"]["list_users"]; got.Name != "list_users" { + t.Errorf("legacy query not lifted into group: %+v", got) + } + data, err := os.ReadFile(GroupFile("ecommerce")) + if err != nil { + t.Fatalf("group file not written: %v", err) + } + if !strings.Contains(string(data), "list_users") { + t.Errorf("group file missing query:\n%s", data) + } +} + +func TestMigrateQueriesToFiles_NoColon(t *testing.T) { + withTempCfg(t) + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "mydb": {Queries: map[string]db.Query{"q": {Name: "q"}}}, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + cfg.MigrateQueriesToFiles() + if cfg.QueryGroups["mydb"]["q"].Name != "q" { + t.Error("no-colon connection did not map to a private group keyed by its name") + } + if _, err := os.Stat(GroupFile("mydb")); err != nil { + t.Errorf("expected queries/mydb.sql: %v", err) + } +} + +func TestMigrateQueriesToFiles_FirstWinsSorted(t *testing.T) { + withTempCfg(t) + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:prod": {Queries: map[string]db.Query{"prod_q": {Name: "prod_q"}}}, + "ecommerce:dev": {Queries: map[string]db.Query{"dev_q": {Name: "dev_q"}}}, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + cfg.MigrateQueriesToFiles() + + // Sorted: "ecommerce:dev" < "ecommerce:prod" → dev wins. + if _, ok := cfg.QueryGroups["ecommerce"]["dev_q"]; !ok { + t.Error("first-wins: expected dev_q in the group") + } + if _, ok := cfg.QueryGroups["ecommerce"]["prod_q"]; ok { + t.Error("first-wins: prod_q should have been dropped") + } + data, _ := os.ReadFile(GroupFile("ecommerce")) + if strings.Contains(string(data), "prod_q") { + t.Errorf("dropped query leaked into file:\n%s", data) + } +} + +func TestMigrateQueriesToFiles_NilSafe(t *testing.T) { + withTempCfg(t) + cfg := &Config{ + Connections: map[string]*ConnectionYAML{"x": {Queries: map[string]db.Query{"q": {Name: "q"}}}}, + } + cfg.MigrateQueriesToFiles() // QueryGroups is nil here + if cfg.QueryGroups == nil { + t.Fatal("MigrateQueriesToFiles did not initialize QueryGroups") + } +} + +func TestMigrateQueriesToFiles_Idempotent(t *testing.T) { + withTempCfg(t) + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": {Queries: map[string]db.Query{"q": {Name: "q"}}}, + }, + QueryGroups: map[string]map[string]db.Query{}, + } + if !cfg.MigrateQueriesToFiles() { + t.Fatal("first migration should report migrated=true") + } + before := cfg.QueryGroups["ecommerce"]["q"].Name + if cfg.MigrateQueriesToFiles() { + t.Error("second migration should be a no-op (migrated=false)") + } + if cfg.QueryGroups["ecommerce"]["q"].Name != before { + t.Error("second migration pass altered already-migrated data") + } +} + +func TestGroupHasOtherMembers(t *testing.T) { + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": {}, + "ecommerce:prod": {}, + "analytics": {}, + }, + } + if !cfg.GroupHasOtherMembers("ecommerce", "ecommerce:dev") { + t.Error("expected a sibling sharing the ecommerce group") + } + if cfg.GroupHasOtherMembers("analytics", "analytics") { + t.Error("standalone connection should have no siblings") + } +} + +func TestLiveConnection_PopulatesQueries(t *testing.T) { + cfg := &Config{ + Connections: map[string]*ConnectionYAML{ + "ecommerce:dev": {Name: "ecommerce:dev", DBType: "sqlite", ConnString: ":memory:"}, + }, + QueryGroups: map[string]map[string]db.Query{ + "ecommerce": {"list_users": {Name: "list_users", Id: 1, SQL: "SELECT 1"}}, + }, + } + conn := cfg.LiveConnection("ecommerce:dev") + qs := conn.GetQueries() + if len(qs) != 1 || qs["list_users"].Name != "list_users" { + t.Errorf("LiveConnection did not populate queries from the shared group: %+v", qs) + } +} diff --git a/internal/config/query_files.go b/internal/config/query_files.go new file mode 100644 index 0000000..8e6ba0d --- /dev/null +++ b/internal/config/query_files.go @@ -0,0 +1,239 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/eduardofuncao/squix/internal/db" +) + +// QueriesDir is the directory holding one .sql file per query group. +func QueriesDir() string { + return filepath.Join(CfgPath, "queries") +} + +// GroupFile returns the path to the .sql file for a group key (already +// sanitized, no extension). +func GroupFile(key string) string { + return filepath.Join(QueriesDir(), key+".sql") +} + +// sanitizeFilename replaces filesystem-unsafe runes with '_'. +func sanitizeFilename(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + if b.Len() == 0 { + return "_" + } + return b.String() +} + +// GroupKey derives the shared query-library key for a connection name (via +// QueryGroupKey) and makes it filesystem-safe. This is both the in-memory +// QueryGroups map key and (with a .sql suffix) the filename, so every place a +// group meets memory or disk stays consistent. +func GroupKey(connName string) string { + return sanitizeFilename(QueryGroupKey(connName)) +} + +// ParseGroupFile parses directive-delimited SQL into queries (without ids). +// +// Format: +// +// -- @query +// [-- @table
] +// [-- @pk [,...]] +// +// +// Content before the first "-- @query" is ignored (preamble). A "-- @query" +// line starts a new block only at BOF or when preceded by a blank line, so a +// stray "-- @query" mid-SQL (no blank line before it) stays part of the body. +func ParseGroupFile(content []byte) (map[string]db.Query, error) { + queries := make(map[string]db.Query) + + var ( + name string + table string + pk []string + body strings.Builder + inBlock bool + inHeader bool + prevBlank = true // BOF counts as blank-preceded + ) + + flush := func() { + if !inBlock { + return + } + sql := strings.TrimSpace(body.String()) + if name == "" { + return + } + q := db.Query{Name: name, SQL: sql} + if table != "" { + q.TableName = table + } + if len(pk) > 0 { + q.PrimaryKeys = pk + } + queries[name] = q + } + + for line := range strings.SplitSeq(string(content), "\n") { + trimmed := strings.TrimSpace(line) + + // Block start: "-- @query ". Before the first block it always + // starts one (no body to protect); once inside a block it must be + // blank-preceded so a stray "-- @query" mid-SQL stays in the body. + if rest, ok := directive(trimmed, "@query"); ok && (prevBlank || !inBlock) { + flush() + name = strings.TrimSpace(rest) + if name == "" { + return nil, fmt.Errorf("missing query name after '-- @query'") + } + table = "" + pk = nil + body.Reset() + inBlock = true + inHeader = true + prevBlank = false + continue + } + + if inBlock && inHeader { + if rest, ok := directive(trimmed, "@table"); ok { + table = strings.TrimSpace(rest) + prevBlank = false + continue + } + if rest, ok := directive(trimmed, "@pk"); ok { + for c := range strings.SplitSeq(rest, ",") { + if c = strings.TrimSpace(c); c != "" { + pk = append(pk, c) + } + } + prevBlank = false + continue + } + // Blank lines inside the header are tolerated; first real line + // begins the SQL body. + if trimmed == "" { + prevBlank = true + continue + } + inHeader = false + } + + if inBlock && !inHeader { + if body.Len() > 0 { + body.WriteByte('\n') + } + body.WriteString(line) + } + + prevBlank = trimmed == "" + } + flush() + + return queries, nil +} + +// directive reports whether trimmed is a "-- @" line and returns the +// trimmed text following the directive. +func directive(trimmed, name string) (string, bool) { + prefix := "-- " + name + rest, ok := strings.CutPrefix(trimmed, prefix) + if !ok { + return "", false + } + // Require a space or end-of-line after the directive name. + if rest == "" { + return "", true + } + if rest[0] == ' ' || rest[0] == '\t' { + return rest, true + } + return "", false +} + +// AssignIDs assigns sequential ids (1-based) to queries sorted by name. +func AssignIDs(queries map[string]db.Query) { + names := make([]string, 0, len(queries)) + for n := range queries { + names = append(names, n) + } + sort.Strings(names) + for i, n := range names { + q := queries[n] + q.Id = i + 1 + queries[n] = q + } +} + +// RenderQuery renders a single query block (no leading/trailing blank lines). +func RenderQuery(q db.Query) string { + var b strings.Builder + fmt.Fprintf(&b, "-- @query %s\n", q.Name) + if q.TableName != "" { + fmt.Fprintf(&b, "-- @table %s\n", q.TableName) + } + if len(q.PrimaryKeys) > 0 { + fmt.Fprintf(&b, "-- @pk %s\n", strings.Join(q.PrimaryKeys, ",")) + } + b.WriteString(strings.TrimSpace(q.SQL)) + return b.String() +} + +// RenderGroup renders all queries in a group, sorted by name, one block per +// query separated by blank lines. +func RenderGroup(queries map[string]db.Query) string { + names := make([]string, 0, len(queries)) + for n := range queries { + names = append(names, n) + } + sort.Strings(names) + + blocks := make([]string, 0, len(names)) + for _, n := range names { + blocks = append(blocks, RenderQuery(queries[n])) + } + return strings.Join(blocks, "\n\n") + "\n" +} + +// WriteGroupFile atomically writes a group's queries to its .sql file. +func WriteGroupFile(key string, queries map[string]db.Query) error { + if err := os.MkdirAll(QueriesDir(), 0755); err != nil { + return err + } + path := GroupFile(key) + tmp, err := os.CreateTemp(QueriesDir(), ".tmp-*.sql") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.WriteString(RenderGroup(queries)); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Chmod(tmpName, 0644); err != nil { + os.Remove(tmpName) + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/config/query_files_test.go b/internal/config/query_files_test.go new file mode 100644 index 0000000..b179ef4 --- /dev/null +++ b/internal/config/query_files_test.go @@ -0,0 +1,170 @@ +package config + +import ( + "os" + "reflect" + "testing" + + "github.com/eduardofuncao/squix/internal/db" +) + +func TestGroupKey(t *testing.T) { + cases := []struct{ name, want string }{ + {"ecommerce:dev", "ecommerce"}, + {"ecommerce:prod", "ecommerce"}, + {"analytics", "analytics"}, + {"a/b:c", "a_b"}, // QueryGroupKey -> "a/b", then sanitized -> "a_b" + {"weird name", "weird_name"}, + } + for _, c := range cases { + if got := GroupKey(c.name); got != c.want { + t.Errorf("GroupKey(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +func TestParseGroupFile_Basic(t *testing.T) { + content := []byte("-- @query a\nSELECT 1\n\n-- @query b\nSELECT 2\n") + got, err := ParseGroupFile(content) + if err != nil { + t.Fatalf("ParseGroupFile: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 queries, got %d: %+v", len(got), got) + } + if got["a"].SQL != "SELECT 1" { + t.Errorf("a.SQL = %q", got["a"].SQL) + } + if got["b"].SQL != "SELECT 2" { + t.Errorf("b.SQL = %q", got["b"].SQL) + } +} + +func TestParseGroupFile_TablePk(t *testing.T) { + content := []byte("-- @query users\n-- @table users\n-- @pk id,org_id\nSELECT * FROM users\n") + got, err := ParseGroupFile(content) + if err != nil { + t.Fatalf("ParseGroupFile: %v", err) + } + q := got["users"] + if q.TableName != "users" { + t.Errorf("TableName = %q", q.TableName) + } + if !reflect.DeepEqual(q.PrimaryKeys, []string{"id", "org_id"}) { + t.Errorf("PrimaryKeys = %v", q.PrimaryKeys) + } +} + +func TestParseGroupFile_PreambleIgnored(t *testing.T) { + // Header/help comments before the first block are ignored, even with no + // blank line between preamble and the first -- @query. + content := []byte("-- Editing queries for: foo\n-- @query a\nSELECT 1\n") + got, err := ParseGroupFile(content) + if err != nil { + t.Fatalf("ParseGroupFile: %v", err) + } + if len(got) != 1 || got["a"].SQL != "SELECT 1" { + t.Fatalf("expected single query a, got %+v", got) + } +} + +func TestParseGroupFile_MidSQLDirectiveStaysInBody(t *testing.T) { + // A "-- @query" inside a body without a preceding blank line is SQL, not a + // new block. + content := []byte("-- @query a\nSELECT 1\n-- @query b\nSELECT 2\n") + got, err := ParseGroupFile(content) + if err != nil { + t.Fatalf("ParseGroupFile: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 query (b absorbed into a), got %d: %+v", len(got), got) + } + if !contains(got["a"].SQL, "-- @query b") { + t.Errorf("expected directive kept in body, got %q", got["a"].SQL) + } +} + +func TestParseGroupFile_MissingName(t *testing.T) { + if _, err := ParseGroupFile([]byte("-- @query\nSELECT 1\n")); err == nil { + t.Error("expected error for missing query name") + } +} + +func TestAssignIDs(t *testing.T) { + queries := map[string]db.Query{ + "zeta": {}, + "alpha": {}, + "mid": {}, + } + AssignIDs(queries) + if queries["alpha"].Id != 1 || queries["mid"].Id != 2 || queries["zeta"].Id != 3 { + t.Errorf("ids not assigned in sorted order: %+v", queries) + } +} + +func TestRenderGroupSorted(t *testing.T) { + queries := map[string]db.Query{ + "b": {Name: "b", SQL: "SELECT 2"}, + "a": {Name: "a", SQL: "SELECT 1", TableName: "t", PrimaryKeys: []string{"id"}}, + } + out := RenderGroup(queries) + // a before b (sorted). + if idxA, idxB := index(out, "-- @query a"), index(out, "-- @query b"); idxA < 0 || idxB < 0 || idxA > idxB { + t.Errorf("expected a before b:\n%s", out) + } + if !contains(out, "-- @table t") || !contains(out, "-- @pk id") { + t.Errorf("expected table/pk directives:\n%s", out) + } +} + +func TestRoundTrip(t *testing.T) { + original := map[string]db.Query{ + "list_orders": {Name: "list_orders", SQL: "SELECT * FROM orders ORDER BY created_at"}, + "users": {Name: "users", SQL: "SELECT id FROM users", TableName: "users", PrimaryKeys: []string{"id"}}, + } + rendered := RenderGroup(original) + parsed, err := ParseGroupFile([]byte(rendered)) + if err != nil { + t.Fatalf("ParseGroupFile: %v", err) + } + if len(parsed) != len(original) { + t.Fatalf("expected %d queries, got %d", len(original), len(parsed)) + } + for name, want := range original { + got, ok := parsed[name] + if !ok { + t.Errorf("query %q lost in round-trip", name) + continue + } + if got.SQL != want.SQL || got.TableName != want.TableName || + !reflect.DeepEqual(got.PrimaryKeys, want.PrimaryKeys) { + t.Errorf("query %q mismatch:\nwant %+v\ngot %+v", name, want, got) + } + } +} + +func TestWriteGroupFile(t *testing.T) { + withTempCfg(t) + queries := map[string]db.Query{"a": {Name: "a", SQL: "SELECT 1"}} + if err := WriteGroupFile("grp", queries); err != nil { + t.Fatalf("WriteGroupFile: %v", err) + } + data, err := os.ReadFile(GroupFile("grp")) + if err != nil { + t.Fatalf("file not written: %v", err) + } + if !contains(string(data), "-- @query a") { + t.Errorf("file missing rendered query:\n%s", data) + } +} + +func contains(s, sub string) bool { return index(s, sub) >= 0 } + +func index(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/config/query_saver.go b/internal/config/query_saver.go index f218209..ae01ed0 100644 --- a/internal/config/query_saver.go +++ b/internal/config/query_saver.go @@ -17,49 +17,21 @@ func GetNextQueryId(queries map[string]db.Query) int { } // SaveQueryToConnection saves a query to a connection, generating an ID if needed -// If the query already exists (by name), it returns an error -// If query.Id == -1, a new ID will be generated func (c *Config) SaveQueryToConnection(connName string, query db.Query) (db.Query, error) { - connData := c.Connections[connName] + queries := c.QueriesFor(connName) - // Check if query with this name already exists (when creating new) if query.Id == -1 { - if _, exists := connData.Queries[query.Name]; exists { + if _, exists := queries[query.Name]; exists { return db.Query{}, fmt.Errorf("query '%s' already exists", query.Name) } - // Generate new ID - query.Id = GetNextQueryId(connData.Queries) + query.Id = GetNextQueryId(queries) } - // Save the query - connData.Queries[query.Name] = query + queries[query.Name] = query - // Save config if err := c.Save(); err != nil { return db.Query{}, err } return query, nil } - -func (c *Config) UpdateLastQuery(connName string, query db.Query) error { - connData := c.Connections[connName] - connData.LastQuery = query - return c.Save() -} - -func (c *Config) SaveQueryAndLast(connName string, query db.Query, saveAsLast bool) error { - connData := c.Connections[connName] - - // Save the query (if it has a name and isn't inline) - if query.Name != "" && query.Name != "" && query.SQL != "" { - connData.Queries[query.Name] = query - } - - // Update last query if requested - if saveAsLast { - connData.LastQuery = query - } - - return c.Save() -} diff --git a/internal/db/base_connection.go b/internal/db/base_connection.go index 1c89ba5..440ef38 100644 --- a/internal/db/base_connection.go +++ b/internal/db/base_connection.go @@ -14,7 +14,6 @@ type BaseConnection struct { ConnString string Schema string Queries map[string]Query - LastQuery Query } func (b *BaseConnection) Open() error { @@ -127,14 +126,6 @@ func (b *BaseConnection) GetDbType() string { return b.DbType } func (b *BaseConnection) GetConnString() string { return b.ConnString } -func (b *BaseConnection) GetLastQuery() Query { return b.LastQuery } - -func (b *BaseConnection) SetLastQuery( - query Query, -) { - b.LastQuery = query -} - func (b *BaseConnection) GetQueries() map[string]Query { return b.Queries } func (b *BaseConnection) SetQueries( diff --git a/internal/db/connection_interface.go b/internal/db/connection_interface.go index 7103fc4..896e13d 100644 --- a/internal/db/connection_interface.go +++ b/internal/db/connection_interface.go @@ -28,9 +28,7 @@ type DatabaseConnection interface { GetConnString() string GetSchema() string GetQueries() map[string]Query - GetLastQuery() Query SetSchema(string) - SetLastQuery(Query) SetQueries(map[string]Query) } diff --git a/internal/db/duckdb_connection.go b/internal/db/duckdb_connection.go index c3201c0..647d253 100644 --- a/internal/db/duckdb_connection.go +++ b/internal/db/duckdb_connection.go @@ -106,9 +106,7 @@ func (d *DuckDBConnection) GetTableMetadata(tableName string) (*TableMetadata, e for pkRows.Next() { var colNames string if err := pkRows.Scan(&colNames); err == nil { - for _, pk := range parseDuckDBArray(colNames) { - metadata.PrimaryKeys = append(metadata.PrimaryKeys, pk) - } + metadata.PrimaryKeys = append(metadata.PrimaryKeys, parseDuckDBArray(colNames)...) } } } diff --git a/internal/db/firebird_connection.go b/internal/db/firebird_connection.go index c547509..51dd91b 100644 --- a/internal/db/firebird_connection.go +++ b/internal/db/firebird_connection.go @@ -58,8 +58,6 @@ func (f *FirebirdConnection) Query(queryName string, args ...any) (any, error) { return nil, fmt.Errorf("query %s not found", queryName) } - f.SetLastQuery(query) - return f.db.Query(query.SQL, args...) } diff --git a/internal/db/sqlserver_connection.go b/internal/db/sqlserver_connection.go index c2285fc..bb0e1ab 100644 --- a/internal/db/sqlserver_connection.go +++ b/internal/db/sqlserver_connection.go @@ -381,13 +381,13 @@ func (s *SQLServerConnection) GetViews() ([]string, error) { } func (s *SQLServerConnection) BuildUpdateStatement(tableName, columnName, currentValue, pkColumn, pkValue string) string { - quotedTableName := fmt.Sprintf("%s", tableName) - quotedColumnName := fmt.Sprintf("%s", columnName) + quotedTableName := tableName + quotedColumnName := columnName escapedValue := strings.ReplaceAll(currentValue, "'", "''") if pkColumn != "" && pkValue != "" { - quotedPkColumn := fmt.Sprintf("%s", pkColumn) + quotedPkColumn := pkColumn escapedPkValue := strings.ReplaceAll(pkValue, "'", "''") return fmt.Sprintf( "-- SQL Server UPDATE statement\nUPDATE %s\nSET %s = '%s'\nWHERE %s = '%s';", @@ -408,8 +408,8 @@ func (s *SQLServerConnection) BuildUpdateStatement(tableName, columnName, curren } func (s *SQLServerConnection) BuildDeleteStatement(tableName, primaryKeyCol, pkValue string) string { - quotedTableName := fmt.Sprintf("%s", tableName) - quotedPkColumn := fmt.Sprintf("%s", primaryKeyCol) + quotedTableName := tableName + quotedPkColumn := primaryKeyCol escapedPkValue := strings.ReplaceAll(pkValue, "'", "''") return fmt.Sprintf( @@ -431,7 +431,7 @@ func (s *SQLServerConnection) ApplyRowLimit(sql string, limit int) string { return sql } - // Don't apply row limit if select-statement contains distinct + // Don't apply row limit if select-statement contains distinct if strings.Contains(trimmedSQL, "DISTINCT") { return sql } diff --git a/internal/explain/explain.go b/internal/explain/explain.go new file mode 100644 index 0000000..1fe70c2 --- /dev/null +++ b/internal/explain/explain.go @@ -0,0 +1,304 @@ +package explain + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/eduardofuncao/squix/internal/db" + "github.com/eduardofuncao/squix/internal/styles" +) + +type relationshipType int + +const ( + belongsTo relationshipType = iota + hasMany + hasOne + hasManyToMany +) + +type relationship struct { + relType relationshipType + column string + referencedTable string + referencedColumn string + junctionTable string // For N:N relationships +} + +func BuildTree(conn db.DatabaseConnection, tableName string, maxDepth int, verbose bool) string { + if maxDepth < 1 { + maxDepth = 1 + } + + visited := make(map[string]bool) + return renderNode(conn, tableName, tableRelationships(conn, tableName), maxDepth, 0, visited, true, verbose) +} + +func tableRelationships(conn db.DatabaseConnection, tableName string) []relationship { + var relationships []relationship + seenTables := make(map[string]bool) + + uniqueConstraints, _ := conn.GetUniqueConstraints(tableName) + uniqueMap := make(map[string]bool) + for _, uc := range uniqueConstraints { + uniqueMap[uc] = true + } + + belongsToFKs, err := conn.GetForeignKeys(tableName) + if err == nil { + for _, fk := range belongsToFKs { + key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) + if seenTables[key] { + continue + } + seenTables[key] = true + + relType := belongsTo + if uniqueMap[fk.Column] { + relType = hasOne + } + + relationships = append(relationships, relationship{ + relType: relType, + column: fk.Column, + referencedTable: fk.ReferencedTable, + referencedColumn: fk.ReferencedColumn, + }) + } + } + + hasManyFKs, err := conn.GetForeignKeysReferencingTable(tableName) + if err == nil { + for _, fk := range hasManyFKs { + if isJunctionTable(conn, fk.ReferencedTable) { + otherTable := getJunctionTableOtherSide(conn, fk.ReferencedTable, tableName) + if otherTable != "" { + key := fmt.Sprintf("nn:%s:%s:%s", otherTable, fk.ReferencedTable, tableName) + if seenTables[key] { + continue + } + seenTables[key] = true + + relationships = append(relationships, relationship{ + relType: hasManyToMany, + referencedTable: otherTable, + junctionTable: fk.ReferencedTable, + }) + } + } else { + otherTableUnique, _ := conn.GetUniqueConstraints(fk.ReferencedTable) + isOneToOne := false + for _, uc := range otherTableUnique { + if uc == fk.Column { + isOneToOne = true + break + } + } + + key := fmt.Sprintf("%s:%s:%s", fk.ReferencedTable, fk.Column, fk.ReferencedColumn) + if seenTables[key] { + continue + } + seenTables[key] = true + + relType := hasMany + if isOneToOne { + relType = hasOne + } + + relationships = append(relationships, relationship{ + relType: relType, + column: fk.Column, + referencedTable: fk.ReferencedTable, + referencedColumn: fk.ReferencedColumn, + }) + } + } + } + + return relationships +} + +func isJunctionTable(conn db.DatabaseConnection, tableName string) bool { + fks, err := conn.GetForeignKeys(tableName) + if err != nil || len(fks) != 2 { + return false + } + + metadata, err := conn.GetTableMetadata(tableName) + if err != nil { + return false + } + + pkMap := make(map[string]bool) + for _, pk := range metadata.PrimaryKeys { + pkMap[pk] = true + } + + bothInPK := true + for _, fk := range fks { + if !pkMap[fk.Column] { + bothInPK = false + break + } + } + + return bothInPK +} + +func getJunctionTableOtherSide(conn db.DatabaseConnection, junctionTable, currentTable string) string { + fks, err := conn.GetForeignKeys(junctionTable) + if err != nil || len(fks) != 2 { + return "" + } + + for _, fk := range fks { + if fk.ReferencedTable != currentTable { + return fk.ReferencedTable + } + } + + return "" +} + +func renderNode( + conn db.DatabaseConnection, + tableName string, + relationships []relationship, + maxDepth, currentDepth int, + visited map[string]bool, + isRoot bool, + verbose bool, +) string { + var builder strings.Builder + + if isRoot { + metadata, _ := conn.GetTableMetadata(tableName) + if len(metadata.PrimaryKeys) > 0 { + pks := strings.Join(metadata.PrimaryKeys, ", ") + builder.WriteString(styles.TableName.Render(tableName)) + builder.WriteString(" ") + builder.WriteString(styles.PrimaryKeyLabel.Render(fmt.Sprintf("(PK: %s)", pks))) + } else { + builder.WriteString(styles.TableName.Render(tableName)) + } + builder.WriteString("\n") + } + + visited[tableName] = true + + if currentDepth >= maxDepth && !isRoot { + return builder.String() + } + + for i, rel := range relationships { + isLast := i == len(relationships)-1 + prefix := "├── " + if isLast { + prefix = "└── " + } + + var relText, cardinality, fkDetails string + var relStyle lipgloss.Style + + isSelfReference := (rel.referencedTable == tableName) + + switch rel.relType { + case belongsTo: + relText = "belongs to" + cardinality = "[N:1]" + relStyle = styles.BelongsToStyle + if verbose { + fkDetails = fmt.Sprintf("(FK: %s → %s.%s)", rel.column, rel.referencedTable, rel.referencedColumn) + } + case hasOne: + relText = "has one" + cardinality = "[1:1]" + relStyle = styles.HasOneStyle + if verbose { + fkDetails = fmt.Sprintf("(FK: %s → %s.%s)", rel.column, rel.referencedTable, rel.referencedColumn) + } + case hasMany: + relText = "has many" + cardinality = "[1:N]" + relStyle = styles.HasManyStyle + if verbose { + fkDetails = fmt.Sprintf("(on: %s ← %s.%s)", rel.referencedColumn, rel.referencedTable, rel.column) + } + case hasManyToMany: + relText = "↔" + cardinality = "[N:N]" + relStyle = styles.HasManyToManyStyle + if verbose { + fkDetails = fmt.Sprintf("(via %s)", rel.junctionTable) + } + } + + builder.WriteString(styles.TreeConnector.Render(prefix)) + if rel.relType == hasManyToMany { + builder.WriteString(relStyle.Render(relText)) + builder.WriteString(" ") + builder.WriteString(styles.TableName.Render(rel.referencedTable)) + builder.WriteString(" ") + builder.WriteString(styles.CardinalityStyle.Render(cardinality)) + } else { + builder.WriteString(relStyle.Render(fmt.Sprintf("%s →", relText))) + builder.WriteString(" ") + builder.WriteString(styles.TableName.Render(rel.referencedTable)) + builder.WriteString(" ") + builder.WriteString(styles.CardinalityStyle.Render(cardinality)) + } + + if verbose && fkDetails != "" { + builder.WriteString(" ") + builder.WriteString(styles.Faint.Render(fkDetails)) + } + + if isSelfReference { + builder.WriteString(" " + styles.Faint.Render("(self-reference)")) + } + + builder.WriteString("\n") + + // Don't show children for self-references or N:N relationships (collapsed) + if isSelfReference || rel.relType == hasManyToMany { + continue + } + + // Don't revisit tables + if visited[rel.referencedTable] { + continue + } + + // Recursively render children if within depth limit + if currentDepth+1 <= maxDepth { + childRelationships := tableRelationships(conn, rel.referencedTable) + if len(childRelationships) > 0 { + childPrefix := " " + if !isLast { + childPrefix = "│ " + } + + localVisited := make(map[string]bool) + for k, v := range visited { + localVisited[k] = v + } + localVisited[rel.referencedTable] = true + + childTree := renderNode(conn, rel.referencedTable, childRelationships, maxDepth, currentDepth+1, localVisited, false, verbose) + lines := strings.Split(childTree, "\n") + for _, line := range lines { + if line == "" { + continue + } + builder.WriteString(styles.TreeConnector.Render(childPrefix)) + builder.WriteString(line) + builder.WriteString("\n") + } + } + } + } + + return builder.String() +} diff --git a/internal/explorer/model.go b/internal/explorer/model.go new file mode 100644 index 0000000..3accddd --- /dev/null +++ b/internal/explorer/model.go @@ -0,0 +1,176 @@ +package explorer + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/eduardofuncao/squix/internal/config" + "github.com/eduardofuncao/squix/internal/db" +) + +type section struct { + title string + items []string + folded bool +} + +type rowKind int + +const ( + rowHeader rowKind = iota + rowItem +) + +type row struct { + kind rowKind + section int + item int +} + +type PendingAction int + +const ( + ActionNone PendingAction = iota + ActionSelectAll + ActionColumns +) + +type Model struct { + width int + height int + + sections []section + rows []row + cursor int + + offsetY int + visibleRows int + + searchMode bool + searchQuery string + searchCursor int + + searchMatches []int + matchIdx int + + spinnerFrame int + relationsMode bool + relationsTarget string + relationsText string + relationsScroll int + relationsLoading bool + relationsLoadStart time.Time + + helpOverlay bool + + conn db.DatabaseConnection + uiVisibility config.UIVisibility + keyMap *config.KeyMap + + pendingAction PendingAction + selectedTable string + + statusMessage string +} + +func New( + conn db.DatabaseConnection, + tables, views []string, + restoreCursor int, + visibility config.UIVisibility, + keyMap *config.KeyMap, +) Model { + if keyMap == nil { + keyMap = config.BuildKeyMap(nil) + } + + m := Model{ + sections: []section{ + {title: "Tables", items: dedupeSorted(tables)}, + {title: "Views", items: dedupeSorted(views)}, + }, + visibleRows: 10, + conn: conn, + uiVisibility: visibility, + keyMap: keyMap, + } + + m.rebuildRows() + if restoreCursor > 0 && restoreCursor < len(m.rows) { + m.cursor = restoreCursor + } + m.clampOffset() + return m +} + +func (m Model) Init() tea.Cmd { + return tea.ClearScreen +} + +func (m Model) PendingAction() PendingAction { + return m.pendingAction +} + +func (m Model) SelectedTable() string { + return m.selectedTable +} + +func (m Model) Cursor() int { + return m.cursor +} + +func (m Model) numRows() int { + return len(m.rows) +} + +func (m Model) selectedItemName() (string, bool) { + if m.cursor < 0 || m.cursor >= len(m.rows) { + return "", false + } + r := m.rows[m.cursor] + if r.kind != rowItem { + return "", false + } + return m.sections[r.section].items[r.item], true +} + +func (m Model) currentSectionIdx() int { + if m.cursor < 0 || m.cursor >= len(m.rows) { + return -1 + } + return m.rows[m.cursor].section +} + +func dedupeSorted(items []string) []string { + seen := make(map[string]bool, len(items)) + out := make([]string, 0, len(items)) + for _, it := range items { + name := strings.TrimSpace(it) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + return out +} + +func (m *Model) rebuildRows() { + var rows []row + for si, sec := range m.sections { + if len(sec.items) == 0 { + continue + } + + rows = append(rows, row{kind: rowHeader, section: si}) + if !sec.folded { + for ii := range sec.items { + rows = append(rows, row{kind: rowItem, section: si, item: ii}) + } + } + } + + m.rows = rows + m.clampCursor() +} diff --git a/internal/explorer/relations.go b/internal/explorer/relations.go new file mode 100644 index 0000000..842f91f --- /dev/null +++ b/internal/explorer/relations.go @@ -0,0 +1,110 @@ +package explorer + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/eduardofuncao/squix/internal/config" + "github.com/eduardofuncao/squix/internal/db" + "github.com/eduardofuncao/squix/internal/explain" + "github.com/eduardofuncao/squix/internal/spinner" + "github.com/eduardofuncao/squix/internal/styles" +) + +const relationsDepth = 2 + +type relationsTreeMsg struct { + target string + tree string +} + +type spinnerTickMsg struct{} + +func (m Model) openRelations() (tea.Model, tea.Cmd) { + name, ok := m.selectedItemName() + if !ok { + m.statusMessage = styles.Faint.Render("Select a table or view first") + return m, nil + } + + m.relationsMode = true + m.relationsTarget = name + m.relationsText = "" + m.relationsScroll = 0 + m.relationsLoading = true + m.relationsLoadStart = time.Now() + + return m, tea.Batch(loadRelationsCmd(m.conn, name), m.spinCmd()) +} + +func loadRelationsCmd(conn db.DatabaseConnection, tableName string) tea.Cmd { + return func() tea.Msg { + return relationsTreeMsg{ + target: tableName, + tree: explain.BuildTree(conn, tableName, relationsDepth, false), + } + } +} + +func (m Model) handleRelationsTree(msg relationsTreeMsg) Model { + if msg.target != m.relationsTarget { + return m + } + m.relationsLoading = false + m.relationsText = msg.tree + m.relationsScroll = 0 + return m +} + +func (m Model) spinCmd() tea.Cmd { + if !m.relationsLoading { + return nil + } + return tea.Tick(spinner.TickInterval, func(time.Time) tea.Msg { + return spinnerTickMsg{} + }) +} + +func (m Model) handleSpinnerTick() Model { + if m.relationsLoading { + m.spinnerFrame = (m.spinnerFrame + 1) % len(spinner.Stages) + } + return m +} + +func (m Model) handleRelationsKeyPress(key string) (tea.Model, tea.Cmd) { + action, matched := m.keyMap.ResolveKey(config.ModeExplorerRelations, key) + if matched { + switch action { + case config.ActionQuit: + m.relationsMode = false + m.relationsText = "" + m.relationsScroll = 0 + m.relationsLoading = false + return m, nil + + case config.ActionMoveUp: + return m.scrollRelations(-1), nil + case config.ActionMoveDown: + return m.scrollRelations(1), nil + } + } + return m, nil +} + +func (m Model) scrollRelations(delta int) Model { + maxScroll := max(len(m.relationsLines())-m.relationsViewportHeight(), 0) + next := clampInt(m.relationsScroll+delta, 0, maxScroll) + m.relationsScroll = next + return m +} + +func clampInt(v, low, high int) int { + if v < low { + return low + } + if v > high { + return high + } + return v +} diff --git a/internal/explorer/search.go b/internal/explorer/search.go new file mode 100644 index 0000000..f47ebfa --- /dev/null +++ b/internal/explorer/search.go @@ -0,0 +1,147 @@ +package explorer + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/eduardofuncao/squix/internal/styles" +) + +func shouldUseCaseSensitive(query string) bool { + for _, r := range query { + if r >= 'A' && r <= 'Z' { + return true + } + } + return false +} + +func containsMatch(value, query string, caseSensitive bool) bool { + if caseSensitive { + return strings.Contains(value, query) + } + return strings.Contains(strings.ToLower(value), strings.ToLower(query)) +} + +func (m Model) startSearch() Model { + m.searchMode = true + m.searchCursor = len(m.searchQuery) + return m +} + +func (m Model) handleSearchInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + return m.executeSearch(), nil + case "esc": + m.searchMode = false + if m.searchQuery == "" { + m.clearSearch() + } + return m, nil + case "backspace": + if m.searchCursor > 0 && len(m.searchQuery) > 0 { + m.searchQuery = m.searchQuery[:m.searchCursor-1] + m.searchQuery[m.searchCursor:] + m.searchCursor-- + } + case "left": + if m.searchCursor > 0 { + m.searchCursor-- + } + case "right": + if m.searchCursor < len(m.searchQuery) { + m.searchCursor++ + } + default: + if len(msg.String()) == 1 { + before := m.searchQuery[:m.searchCursor] + after := "" + if m.searchCursor < len(m.searchQuery) { + after = m.searchQuery[m.searchCursor:] + } + m.searchQuery = before + msg.String() + after + m.searchCursor++ + } + } + return m, nil +} + +func (m Model) executeSearch() Model { + m.searchMode = false + + if m.searchQuery == "" { + m.clearSearch() + return m + } + + caseSensitive := shouldUseCaseSensitive(m.searchQuery) + m.searchMatches = m.searchMatches[:0] + for i, r := range m.rows { + if r.kind != rowItem { + continue + } + item := m.sections[r.section].items[r.item] + if containsMatch(item, m.searchQuery, caseSensitive) { + m.searchMatches = append(m.searchMatches, i) + } + } + + switch count := len(m.searchMatches); count { + case 0: + m.matchIdx = 0 + m.statusMessage = styles.Error.Render( + fmt.Sprintf("No matches for %q", m.searchQuery), + ) + default: + m.jumpToFirstMatchFrom(m.cursor) + m.statusMessage = styles.Faint.Render( + fmt.Sprintf("Found %d matches for %q", count, m.searchQuery), + ) + } + return m +} + +func (m *Model) jumpToFirstMatchFrom(from int) { + for idx, rowIdx := range m.searchMatches { + if rowIdx >= from { + m.matchIdx = idx + m.cursor = rowIdx + m.clampOffset() + return + } + } + m.matchIdx = 0 + m.cursor = m.searchMatches[0] + m.clampOffset() +} + +func (m Model) clearSearch() Model { + m.searchMode = false + m.searchQuery = "" + m.searchCursor = 0 + m.searchMatches = nil + m.matchIdx = 0 + m.statusMessage = "" + return m +} + +func (m Model) nextMatch() Model { + if len(m.searchMatches) == 0 { + return m + } + m.matchIdx = (m.matchIdx + 1) % len(m.searchMatches) + m.cursor = m.searchMatches[m.matchIdx] + m.clampOffset() + return m +} + +func (m Model) prevMatch() Model { + if len(m.searchMatches) == 0 { + return m + } + m.matchIdx = (m.matchIdx - 1 + len(m.searchMatches)) % len(m.searchMatches) + m.cursor = m.searchMatches[m.matchIdx] + m.clampOffset() + return m +} diff --git a/internal/explorer/table.go b/internal/explorer/table.go new file mode 100644 index 0000000..7b1e262 --- /dev/null +++ b/internal/explorer/table.go @@ -0,0 +1,23 @@ +package explorer + +import ( + tea "charm.land/bubbletea/v2" + "github.com/eduardofuncao/squix/internal/config" + "github.com/eduardofuncao/squix/internal/db" +) + +func Render( + conn db.DatabaseConnection, + tables, views []string, + restoreCursor int, + visibility config.UIVisibility, + keyMap *config.KeyMap, +) (Model, error) { + model := New(conn, tables, views, restoreCursor, visibility, keyMap) + p := tea.NewProgram(model) + finalModel, err := p.Run() + if err != nil { + return model, err + } + return finalModel.(Model), nil +} diff --git a/internal/explorer/update.go b/internal/explorer/update.go new file mode 100644 index 0000000..260cdb1 --- /dev/null +++ b/internal/explorer/update.go @@ -0,0 +1,248 @@ +package explorer + +import ( + tea "charm.land/bubbletea/v2" + "github.com/eduardofuncao/squix/internal/config" + "github.com/eduardofuncao/squix/internal/styles" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.handleKeyPress(msg) + case relationsTreeMsg: + return m.handleRelationsTree(msg), nil + case spinnerTickMsg: + return m.handleSpinnerTick(), m.spinCmd() + case tea.WindowSizeMsg: + return m.handleWindowResize(msg), nil + } + return m, nil +} + +func (m Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + return m, tea.Quit + } + + if m.helpOverlay { + action, matched := m.keyMap.ResolveKey(config.ModeHelp, key) + if matched && action == config.ActionHelpClose { + m.helpOverlay = false + } + return m, nil + } + + if m.relationsMode { + return m.handleRelationsKeyPress(key) + } + + if m.searchMode { + return m.handleSearchInput(msg) + } + + if key == "esc" && len(m.searchMatches) > 0 { + return m.clearSearch(), nil + } + + action, matched := m.keyMap.ResolveKey(config.ModeExplorer, key) + if !matched { + return m, nil + } + + switch action { + case config.ActionQuit: + return m, tea.Quit + case config.ActionHelp: + m.helpOverlay = true + return m, nil + case config.ActionToggleFooter: + m.uiVisibility.FooterKeymaps = !m.uiVisibility.FooterKeymaps + return m, nil + + case config.ActionMoveUp: + return m.moveUp(), nil + case config.ActionMoveDown: + return m.moveDown(), nil + case config.ActionJumpFirstRow: + return m.jumpToFirstRow(), nil + case config.ActionJumpLastRow: + return m.jumpToLastRow(), nil + case config.ActionPageUp: + return m.pageUp(), nil + case config.ActionPageDown: + return m.pageDown(), nil + + case config.ActionExplorerSelect: + return m.selectForQuery(ActionSelectAll) + case config.ActionExplorerColumns: + return m.selectForQuery(ActionColumns) + case config.ActionExplorerRelations: + return m.openRelations() + case config.ActionExplorerToggleFold: + return m.toggleFold(), nil + + case config.ActionSearch: + return m.startSearch(), nil + case config.ActionNextMatch: + return m.nextMatch(), nil + case config.ActionPrevMatch: + return m.prevMatch(), nil + } + + return m, nil +} + +func (m Model) selectForQuery(action PendingAction) (tea.Model, tea.Cmd) { + name, ok := m.selectedItemName() + if !ok { + m.statusMessage = styles.Faint.Render("Select a table or view first") + return m, nil + } + m.pendingAction = action + m.selectedTable = name + return m, tea.Quit +} + +func (m Model) moveUp() Model { + if m.cursor > 0 { + m.cursor-- + m.clampOffset() + } + return m +} + +func (m Model) moveDown() Model { + if m.cursor < m.numRows()-1 { + m.cursor++ + m.clampOffset() + } + return m +} + +func (m Model) jumpToFirstRow() Model { + m.cursor = 0 + m.offsetY = 0 + return m +} + +func (m Model) jumpToLastRow() Model { + m.cursor = m.numRows() - 1 + m.clampOffset() + return m +} + +func (m Model) pageUp() Model { + m.cursor -= m.visibleRows + if m.cursor < 0 { + m.cursor = 0 + } + m.clampOffset() + return m +} + +func (m Model) pageDown() Model { + if m.numRows() == 0 { + return m + } + m.cursor += m.visibleRows + if m.cursor >= m.numRows() { + m.cursor = m.numRows() - 1 + } + m.clampOffset() + return m +} + +func (m Model) toggleFold() Model { + si := m.currentSectionIdx() + if si < 0 || si >= len(m.sections) { + return m + } + + prevKind := m.rows[m.cursor].kind + prevItem := m.rows[m.cursor].item + wasFolded := m.sections[si].folded + m.sections[si].folded = !m.sections[si].folded + m.rebuildRows() + if len(m.searchMatches) > 0 { + m.searchMatches = nil + m.matchIdx = 0 + } + + if wasFolded { + m.setCursorToHeader(si) + } else if prevKind == rowItem { + if !m.setCursorToItem(si, prevItem) { + m.setCursorToHeader(si) + } + } else { + m.setCursorToHeader(si) + } + return m +} + +func (m *Model) setCursorToHeader(sectionIdx int) { + for i, r := range m.rows { + if r.kind == rowHeader && r.section == sectionIdx { + m.cursor = i + m.clampOffset() + return + } + } + m.clampCursor() +} + +func (m *Model) setCursorToItem(sectionIdx, itemIdx int) bool { + for i, r := range m.rows { + if r.kind == rowItem && r.section == sectionIdx && r.item == itemIdx { + m.cursor = i + m.clampOffset() + return true + } + } + return false +} + +func (m *Model) clampCursor() { + if m.cursor >= m.numRows() { + m.cursor = m.numRows() - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +func (m *Model) clampOffset() { + m.clampCursor() + if m.visibleRows <= 0 { + return + } + if m.cursor < m.offsetY { + m.offsetY = m.cursor + } + if m.cursor >= m.offsetY+m.visibleRows { + m.offsetY = m.cursor - m.visibleRows + 1 + } + if m.offsetY > max(m.numRows()-m.visibleRows, 0) { + m.offsetY = max(m.numRows()-m.visibleRows, 0) + } + if m.offsetY < 0 { + m.offsetY = 0 + } +} + +func (m Model) handleWindowResize(msg tea.WindowSizeMsg) Model { + m.width = msg.Width + m.height = msg.Height + m.visibleRows = m.height - chromeLines() + if m.visibleRows > m.numRows() { + m.visibleRows = m.numRows() + } + if m.visibleRows < 3 { + m.visibleRows = 3 + } + m.clampOffset() + return m +} diff --git a/internal/explorer/view.go b/internal/explorer/view.go new file mode 100644 index 0000000..ab27bc0 --- /dev/null +++ b/internal/explorer/view.go @@ -0,0 +1,292 @@ +package explorer + +import ( + "fmt" + "slices" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/eduardofuncao/squix/internal/config" + "github.com/eduardofuncao/squix/internal/spinner" + "github.com/eduardofuncao/squix/internal/styles" +) + +// number of screen lines reserved for the title, separato and footer areas +func chromeLines() int { return 6 } + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + if m.helpOverlay { + return tea.NewView(m.renderHelpOverlay()) + } + + if m.relationsMode { + return tea.NewView(m.renderRelationsPanel()) + } + + return tea.NewView(m.renderListView()) +} + +func (m Model) renderListView() string { + var b strings.Builder + + title := styles.Title.Render("◆ explore") + if m.conn != nil { + title += styles.Faint.Render( + fmt.Sprintf(" · %s (%s)", m.conn.GetName(), m.conn.GetDbType()), + ) + } + b.WriteString(title) + b.WriteString("\n") + + b.WriteString(styles.Separator.Render(strings.Repeat("─", max(m.width-1, 1)))) + b.WriteString("\n") + + if m.numRows() == 0 { + b.WriteString(styles.Faint.Render("No tables or views found")) + } + + endRow := min(m.offsetY+m.visibleRows, m.numRows()) + for i := m.offsetY; i < endRow; i++ { + b.WriteString(m.renderRow(i)) + b.WriteString("\n") + } + + b.WriteString(m.renderListFooter()) + + if m.statusMessage != "" { + b.WriteString("\n") + b.WriteString(m.statusMessage) + } + + return b.String() +} + +func (m Model) renderRow(idx int) string { + r := m.rows[idx] + selected := idx == m.cursor + + if r.kind == rowHeader { + sec := m.sections[r.section] + marker := "▾" + if sec.folded { + marker = "▸" + } + label := fmt.Sprintf("%s %s %s", marker, sec.title, styles.Faint.Render(fmt.Sprintf("(%d)", len(sec.items)))) + if selected { + return styles.TableSelected.Render(label) + } + return styles.TableHeader.Render(label) + } + + item := m.sections[r.section].items[r.item] + prefix := " " + switch { + case selected: + return styles.TableSelected.Render(prefix + item) + case isSearchMatch(idx, m.searchMatches): + return styles.SearchMatch.Render(prefix + item) + default: + return styles.TableCell.Render(prefix + item) + } +} + +func isSearchMatch(idx int, matches []int) bool { + return slices.Contains(matches, idx) +} + +func (m Model) renderListFooter() string { + var b strings.Builder + + if m.searchMode { + cursorBefore := m.searchQuery[:m.searchCursor] + cursorAfter := "" + if m.searchCursor < len(m.searchQuery) { + cursorAfter = m.searchQuery[m.searchCursor:] + } + + input := styles.SearchMatch.Render("/") + " " + cursorBefore + "█" + cursorAfter + b.WriteString("\n") + b.WriteString(input) + b.WriteString("\n") + b.WriteString(styles.Faint.Render("Enter: search Esc: cancel")) + return b.String() + } + + if m.uiVisibility.FooterKeymaps { + km := m.keyMap + var parts []string + addEntry := func(key, desc string) { + parts = append(parts, styles.TableHeader.Render(key)+":"+styles.Faint.Render(desc)) + } + addEntry(km.FirstKey(config.ActionMoveUp)+km.FirstKey(config.ActionMoveDown), "↑↓") + addEntry(km.FirstKey(config.ActionExplorerSelect), "select *") + addEntry(km.FirstKey(config.ActionExplorerColumns), "columns") + addEntry(km.FirstKey(config.ActionExplorerRelations), "relations") + addEntry(km.FirstKey(config.ActionExplorerToggleFold), "fold") + addEntry(km.FirstKey(config.ActionSearch), "search") + addEntry(km.FirstKey(config.ActionHelp), "help") + addEntry(km.FirstKey(config.ActionQuit), "quit") + b.WriteString("\n") + b.WriteString(" " + strings.Join(parts, " ")) + } else { + b.WriteString("\n") + } + + return b.String() +} + +func (m Model) renderRelationsPanel() string { + var b strings.Builder + + loadingLine := "" + if m.relationsLoading { + stage := spinner.Stages[m.spinnerFrame%len(spinner.Stages)] + elapsed := time.Since(m.relationsLoadStart).Seconds() + loadingLine = styles.Success.Render(stage) + fmt.Sprintf(" %.2fs", elapsed) + } + + lines := m.relationsLines() + availableHeight := m.relationsViewportHeight() + panelHeight := min(len(lines), availableHeight) + if panelHeight < 1 { + panelHeight = 1 + } + + start := m.relationsScroll + end := min(start+panelHeight, len(lines)) + + titleText := fmt.Sprintf("◆ relationships · %s (depth %d)", m.relationsTarget, relationsDepth) + panelWidth := lipgloss.Width(titleText) + maxLineWidth := max(m.width-2, 20) + + visible := make([]string, 0, end-start) + for i := start; i < end; i++ { + line := truncateANSI(lines[i], maxLineWidth) + visible = append(visible, line) + if w := lipgloss.Width(line); w > panelWidth { + panelWidth = w + } + } + scrollInfo := "" + if !m.relationsLoading && len(lines) > panelHeight { + scrollInfo = fmt.Sprintf(" [%d-%d of %d]", start+1, end, len(lines)) + } + km := m.keyMap + footerText := "" + if !m.relationsLoading { + footerText = km.FirstKey(config.ActionMoveDown) + "/" + + km.FirstKey(config.ActionMoveUp) + " scroll" + scrollInfo + + " " + km.DisplayKeys(config.ActionQuit) + " close" + } + + panelWidth = min(max(panelWidth+2, 24), max(m.width-1, 1)) + + b.WriteString(styles.Title.Render(titleText)) + b.WriteString("\n") + + b.WriteString(styles.Separator.Render(strings.Repeat("─", panelWidth))) + + if m.relationsLoading { + b.WriteString("\n") + b.WriteString(loadingLine) + } else { + for _, line := range visible { + b.WriteString("\n") + b.WriteString(styles.TableCell.Render(line)) + } + } + + b.WriteString(footerText) + + return b.String() +} + +func (m Model) relationsViewportHeight() int { + h := m.height - 3 + if h < 3 { + h = 3 + } + return h +} + +func (m Model) relationsLines() []string { + return strings.Split(m.relationsText, "\n") +} + +func truncateANSI(line string, width int) string { + if lipgloss.Width(line) <= width { + return line + } + return ansi.Truncate(line, max(width-1, 1), "…") +} + +func (m Model) renderHelpOverlay() string { + type keyBind struct { + keys string + desc string + } + type category struct { + name string + binds []keyBind + } + + km := m.keyMap + + categories := []category{ + {"Navigation", []keyBind{ + {km.DisplayKeys(config.ActionMoveUp), "Move up"}, + {km.DisplayKeys(config.ActionMoveDown), "Move down"}, + {km.DisplayKeys(config.ActionJumpFirstRow), "Jump to first row"}, + {km.DisplayKeys(config.ActionJumpLastRow), "Jump to last row"}, + {km.DisplayKeys(config.ActionPageUp), "Page up"}, + {km.DisplayKeys(config.ActionPageDown), "Page down"}, + }}, + {"Actions", []keyBind{ + {km.DisplayKeys(config.ActionExplorerSelect), "SELECT * from item"}, + {km.DisplayKeys(config.ActionExplorerColumns), "Show columns metadata"}, + {km.DisplayKeys(config.ActionExplorerRelations), "Show relationships tree"}, + {km.DisplayKeys(config.ActionExplorerToggleFold), "Fold/unfold section"}, + }}, + {"Search", []keyBind{ + {km.DisplayKeys(config.ActionSearch), "Search items"}, + {km.DisplayKeys(config.ActionNextMatch) + " / " + km.DisplayKeys(config.ActionPrevMatch), "Next/previous match"}, + }}, + {"Other", []keyBind{ + {km.DisplayKeys(config.ActionToggleFooter), "Toggle footer keybinds"}, + {km.DisplayKeys(config.ActionHelp), "Show/hide this help"}, + {km.DisplayKeys(config.ActionQuit), "Quit"}, + }}, + } + + separatorWidth := max(m.width-4, 0) + + var b strings.Builder + b.WriteString(styles.Title.Render("Keyboard Shortcuts")) + b.WriteString("\n") + b.WriteString(styles.Separator.Render(strings.Repeat("─", separatorWidth))) + b.WriteString("\n\n") + + for _, cat := range categories { + b.WriteString(styles.TableHeader.Render(cat.name)) + b.WriteString("\n") + for _, bind := range cat.binds { + line := fmt.Sprintf(" %-16s %s", bind.keys, bind.desc) + b.WriteString(styles.Faint.Render(line)) + b.WriteString("\n") + } + b.WriteString("\n") + } + + b.WriteString(styles.Separator.Render(strings.Repeat("─", separatorWidth))) + b.WriteString("\n") + b.WriteString(styles.Faint.Render(km.DisplayKeys(config.ActionHelpClose) + " to close")) + + return b.String() +} diff --git a/internal/initui/input.go b/internal/initui/input.go index d399105..1eae98b 100644 --- a/internal/initui/input.go +++ b/internal/initui/input.go @@ -73,9 +73,10 @@ func (m InitInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursorIndex < fieldConnString { m.cursorIndex++ // Update cursor position for the new field - if m.cursorIndex == fieldName { + switch m.cursorIndex { + case fieldName: m.nameCursor = len(m.name) - } else if m.cursorIndex == fieldConnString { + case fieldConnString: m.connCursor = len(m.connString) } } @@ -84,23 +85,25 @@ func (m InitInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursorIndex > fieldName { m.cursorIndex-- // Update cursor position for the new field - if m.cursorIndex == fieldName { + switch m.cursorIndex { + case fieldName: m.nameCursor = len(m.name) - } else if m.cursorIndex == fieldConnString { + case fieldConnString: m.connCursor = len(m.connString) } } case "right": // Cycle through db types when on type field - if m.cursorIndex == fieldType { + switch m.cursorIndex { + case fieldType: m.cycleDbType(1) - } else if m.cursorIndex == fieldName { + case fieldName: // Move cursor right within name field if m.nameCursor < len(m.name) { m.nameCursor++ } - } else if m.cursorIndex == fieldConnString { + case fieldConnString: // Move cursor right within conn-string field if m.connCursor < len(m.connString) { m.connCursor++ @@ -109,14 +112,15 @@ func (m InitInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "left": // Cycle through db types when on type field - if m.cursorIndex == fieldType { + switch m.cursorIndex { + case fieldType: m.cycleDbType(-1) - } else if m.cursorIndex == fieldName { + case fieldName: // Move cursor left within name field if m.nameCursor > 0 { m.nameCursor-- } - } else if m.cursorIndex == fieldConnString { + case fieldConnString: // Move cursor left within conn-string field if m.connCursor > 0 { m.connCursor-- diff --git a/internal/params/substitute_test.go b/internal/params/substitute_test.go index 7a3a9a2..00f7d1d 100644 --- a/internal/params/substitute_test.go +++ b/internal/params/substitute_test.go @@ -26,7 +26,7 @@ func newMockConn(placeholder string) db.DatabaseConnection { // needed because BaseConnection.Query returns wrong error message func (m *mockConn) Query(string, ...any) (any, error) { return nil, nil } func (m *mockConn) ExecQuery(string, ...any) (*sql.Rows, error) { return nil, nil } -func (m *mockConn) Exec(string, ...any) (sql.Result, error) { return nil, nil } +func (m *mockConn) Exec(string, ...any) (sql.Result, error) { return nil, nil } func (m *mockConn) Open() error { return nil } func (m *mockConn) Ping() error { return nil } func (m *mockConn) Close() error { return nil } diff --git a/internal/run/executor.go b/internal/run/executor.go index 6fd7b9d..9dfda58 100644 --- a/internal/run/executor.go +++ b/internal/run/executor.go @@ -49,27 +49,24 @@ func ExecuteSelect(sql, queryName string, params ExecutionParams) error { // Execute the query with or without parameters var err error var rows any - if params.Args != nil && len(params.Args) > 0 { + if len(params.Args) > 0 { rows, err = params.Connection.ExecQuery(sql, params.Args...) } else { rows, err = params.Connection.ExecQuery(sql) } if err != nil { - done <- struct{}{} - fmt.Print("\r\033[2K") + spinner.Stop(done) return fmt.Errorf("query execution failed: %w", err) } // Format the results columns, columnTypes, data, err := db.FormatTableDataWithTypes(rows.(*stdlib.Rows)) if err != nil { - done <- struct{}{} - fmt.Print("\r\033[2K") + spinner.Stop(done) return fmt.Errorf("formatting failed: %w", err) } - done <- struct{}{} - fmt.Print("\r\033[2K") + spinner.Stop(done) elapsed := time.Since(start) if len(data) == 0 { fmt.Println("No results found") @@ -121,13 +118,12 @@ func ExecuteNonSelect(params ExecutionParams) { var result stdlib.Result var err error - if params.Args != nil && len(params.Args) > 0 { + if len(params.Args) > 0 { result, err = params.Connection.Exec(params.Query.SQL, params.Args...) } else { result, err = params.Connection.Exec(params.Query.SQL) } - done <- struct{}{} - fmt.Print("\r\033[2K") + spinner.Stop(done) elapsed := time.Since(start) if err != nil { @@ -171,7 +167,7 @@ func ExecuteExportWithOpenConn(params ExecutionParams, format string) error { // Non-SELECT start := time.Now() var err error - if params.Args != nil && len(params.Args) > 0 { + if len(params.Args) > 0 { _, err = params.Connection.Exec(params.Query.SQL, params.Args...) } else { _, err = params.Connection.Exec(params.Query.SQL) @@ -200,7 +196,7 @@ func executeExportSelect(sql string, params ExecutionParams, format string) erro // Execute query var err error var rows any - if params.Args != nil && len(params.Args) > 0 { + if len(params.Args) > 0 { rows, err = params.Connection.ExecQuery(sql, params.Args...) } else { rows, err = params.Connection.ExecQuery(sql) diff --git a/internal/run/resolver.go b/internal/run/resolver.go index 5233f39..372892a 100644 --- a/internal/run/resolver.go +++ b/internal/run/resolver.go @@ -13,19 +13,24 @@ import ( // 2. Inline SQL (if selector looks like SQL) // 3. Saved query by name/ID // 4. Create new in editor (default) -func ResolveQuery(flags Flags, cfg *config.Config, currentConn string, conn db.DatabaseConnection) (ResolvedQuery, error) { - // Priority 1: Last query with --last/-l flag +func ResolveQuery(flags Flags, currentConn string, conn db.DatabaseConnection) (ResolvedQuery, error) { + // Priority 1: Last query with --last/-l flag (read from last-query.sql) if flags.LastQuery { if currentConn == "" { return ResolvedQuery{}, fmt.Errorf("no active connection") } - lastQuery := cfg.Connections[currentConn].LastQuery - if lastQuery.Name == "" { + sql, err := config.LoadLastQuery() + if err != nil { + return ResolvedQuery{}, fmt.Errorf("could not read last query: %w", err) + } + if sql == "" { return ResolvedQuery{}, fmt.Errorf("no last query found. Run a query first, then use squix run --last") } + // Nameless: the file holds SQL only. Saveable is false so a table-view + // save creates a new query instead of overwriting in place. return ResolvedQuery{ - Query: lastQuery, - Saveable: true, + Query: db.Query{Name: "", SQL: sql, Id: -1}, + Saveable: false, }, nil } diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go index 9e49a90..7e6ef4e 100644 --- a/internal/spinner/spinner.go +++ b/internal/spinner/spinner.go @@ -5,17 +5,27 @@ import ( "os" "time" - isatty "github.com/mattn/go-isatty" "github.com/eduardofuncao/squix/internal/styles" + isatty "github.com/mattn/go-isatty" ) -// Interactive reports whether stdout is a terminal. When false (e.g. piped to -// an editor or CI), spinner frames and cursor-control escapes would land as -// literal garbage in the captured output, so animation and erasure are skipped. func Interactive() bool { return isatty.IsTerminal(os.Stdout.Fd()) } +var Stages = []string{" ", ".", "o", "O", "@", "*"} + +// TickInterval is how often spinner frames advance. +const TickInterval = 100 * time.Millisecond + +// Stop signals the spinner goroutine to exit and erases its line +func Stop(done chan struct{}) { + close(done) + if Interactive() { + fmt.Print("\r\033[2K") + } +} + func Wait(done chan struct{}) { if !Interactive() { <-done @@ -42,16 +52,14 @@ func CircleWait(done chan struct{}) { <-done return } - // Custom pulsing animation - stages := []string{" ", ".", "o", "O", "@", "*"} for { - for _, s := range stages { + for _, s := range Stages { select { case <-done: return default: fmt.Printf("\r%s Checking...", styles.Success.Render(s)) - time.Sleep(100 * time.Millisecond) + time.Sleep(TickInterval) } } } @@ -62,18 +70,16 @@ func CircleWaitWithTimer(done chan struct{}) { <-done return } - // Custom pulsing animation with timer - stages := []string{" ", ".", "o", "O", "@", "*"} var passed time.Duration = 0 for { - for _, s := range stages { + for _, s := range Stages { select { case <-done: return default: fmt.Printf("\r%s %.2fs", styles.Success.Render(s), passed.Seconds()) - passed += 100 * time.Millisecond - time.Sleep(100 * time.Millisecond) + passed += TickInterval + time.Sleep(TickInterval) } } } diff --git a/internal/table/editutils.go b/internal/table/editutils.go index a80cf08..9021a13 100644 --- a/internal/table/editutils.go +++ b/internal/table/editutils.go @@ -33,10 +33,10 @@ func buildEditorCommand(editorCmd, tmpPath, content string, cursorHint cursorPos type cursorPositionHint int const ( - CursorAtTop cursorPositionHint = iota // Top of file (line 1, col 1) - CursorAtUpdateValue // Inside the value in UPDATE SET col = 'value' - CursorAtWhereClause // Inside the value in WHERE col = 'value' - CursorAtEndOfFile // At the end of the file + CursorAtTop cursorPositionHint = iota // Top of file (line 1, col 1) + CursorAtUpdateValue // Inside the value in UPDATE SET col = 'value' + CursorAtWhereClause // Inside the value in WHERE col = 'value' + CursorAtEndOfFile // At the end of the file ) func findCursorPosition(content string, hint cursorPositionHint) (line int, col int) { diff --git a/internal/table/export.go b/internal/table/export.go index daad21b..8bf55b3 100644 --- a/internal/table/export.go +++ b/internal/table/export.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/atotto/clipboard" tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" ) type exportFormat string @@ -273,7 +273,7 @@ func FormatHTML(headers []string, rows [][]string, opts FormatOptions) (string, if opts.DbName != "" || opts.DbType != "" { title = fmt.Sprintf("%s (%s/%s)", escapeHTML(opts.QueryName), escapeHTML(opts.DbName), escapeHTML(opts.DbType)) } - buf.WriteString(fmt.Sprintf("

%s

\n", title)) + fmt.Fprintf(&buf, "

%s

\n", title) } // Table structure @@ -281,7 +281,7 @@ func FormatHTML(headers []string, rows [][]string, opts FormatOptions) (string, buf.WriteString("
\n") buf.WriteString("\n") for _, header := range headers { - buf.WriteString(fmt.Sprintf("\n", escapeHTML(header))) + fmt.Fprintf(&buf, "\n", escapeHTML(header)) } buf.WriteString("\n") buf.WriteString("\n") @@ -293,9 +293,9 @@ func FormatHTML(headers []string, rows [][]string, opts FormatOptions) (string, if i%2 == 1 { rowClass = " class=\"odd\"" } - buf.WriteString(fmt.Sprintf("\n", rowClass)) + fmt.Fprintf(&buf, "\n", rowClass) for _, cell := range row { - buf.WriteString(fmt.Sprintf("\n", escapeHTML(cell))) + fmt.Fprintf(&buf, "\n", escapeHTML(cell)) } buf.WriteString("\n") } @@ -325,7 +325,7 @@ func FormatSQL(headers []string, rows [][]string, opts FormatOptions) (string, e var buf strings.Builder for _, row := range rows { - buf.WriteString(fmt.Sprintf("INSERT INTO %s (", opts.TableName)) + fmt.Fprintf(&buf, "INSERT INTO %s (", opts.TableName) columns := make([]string, 0, len(headers)) for _, header := range headers { diff --git a/internal/table/footer_test.go b/internal/table/footer_test.go new file mode 100644 index 0000000..b0d016f --- /dev/null +++ b/internal/table/footer_test.go @@ -0,0 +1,177 @@ +package table + +import ( + "strings" + "testing" + + "github.com/eduardofuncao/squix/internal/config" +) + +func TestCellMatchProgress(t *testing.T) { + // Progress comes from the stored index, not the live cursor. + m := Model{searchMatches: []CellPosition{{0, 0}, {1, 2}, {3, 1}}} + + m.cellMatchIdx = 1 + if cur, tot := m.cellMatchProgress(); cur != 2 || tot != 3 { + t.Errorf("idx=1: got (%d,%d), want (2,3)", cur, tot) + } + + m.cellMatchIdx = 0 + if cur, tot := m.cellMatchProgress(); cur != 1 || tot != 3 { + t.Errorf("idx=0: got (%d,%d), want (1,3)", cur, tot) + } + + m.searchMatches = nil + if cur, tot := m.cellMatchProgress(); cur != 0 || tot != 0 { + t.Errorf("empty: got (%d,%d), want (0,0)", cur, tot) + } +} + +func TestColMatchProgress(t *testing.T) { + m := Model{searchColMatches: []int{0, 3, 5}} + + m.colMatchIdx = 2 + if cur, tot := m.colMatchProgress(); cur != 3 || tot != 3 { + t.Errorf("idx=2: got (%d,%d), want (3,3)", cur, tot) + } + + m.colMatchIdx = 0 + if cur, tot := m.colMatchProgress(); cur != 1 || tot != 3 { + t.Errorf("idx=0: got (%d,%d), want (1,3)", cur, tot) + } +} + +func TestMatchLabel(t *testing.T) { + if got := matchLabel(3, 12); got != "[3/12]" { + t.Errorf("got %q, want %q", got, "[3/12]") + } + if got := matchLabel(1, 1); got != "[1/1]" { + t.Errorf("got %q, want %q", got, "[1/1]") + } +} + +func TestCyclerSetsCellMatchIdx(t *testing.T) { + m := Model{searchMatches: []CellPosition{{0, 0}, {0, 2}, {2, 0}}} + + // From match 0 → next lands on match 1 ({0,2}). + m.selectedRow, m.selectedCol = 0, 0 + m = m.nextSearchMatch() + if m.cellMatchIdx != 1 || m.selectedRow != 0 || m.selectedCol != 2 { + t.Errorf("next from 0: idx=%d pos=(%d,%d), want idx=1 pos=(0,2)", m.cellMatchIdx, m.selectedRow, m.selectedCol) + } + + // → next lands on match 2 ({2,0}). + m = m.nextSearchMatch() + if m.cellMatchIdx != 2 || m.selectedRow != 2 || m.selectedCol != 0 { + t.Errorf("next from 1: idx=%d pos=(%d,%d), want idx=2 pos=(2,0)", m.cellMatchIdx, m.selectedRow, m.selectedCol) + } + + // → wrap to match 0. + m = m.nextSearchMatch() + if m.cellMatchIdx != 0 { + t.Errorf("wrap next: idx=%d, want 0", m.cellMatchIdx) + } + + // prev from match 0 → wrap to last (match 2). + m = m.prevSearchMatch() + if m.cellMatchIdx != 2 { + t.Errorf("wrap prev: idx=%d, want 2", m.cellMatchIdx) + } +} + +func TestCyclerSetsColMatchIdx(t *testing.T) { + m := Model{searchColMatches: []int{1, 3, 5}} + + m.selectedCol = 1 + m = m.nextColumnMatch() + if m.colMatchIdx != 1 || m.selectedCol != 3 { + t.Errorf("next col: idx=%d col=%d, want idx=1 col=3", m.colMatchIdx, m.selectedCol) + } + + // prev from col 3 → match 0 (col 1). + m = m.prevColumnMatch() + if m.colMatchIdx != 0 || m.selectedCol != 1 { + t.Errorf("prev col: idx=%d col=%d, want idx=0 col=1", m.colMatchIdx, m.selectedCol) + } + + // prev from col 1 → wrap to last (col 5, idx 2). + m = m.prevColumnMatch() + if m.colMatchIdx != 2 || m.selectedCol != 5 { + t.Errorf("wrap prev col: idx=%d col=%d, want idx=2 col=5", m.colMatchIdx, m.selectedCol) + } +} + +func TestSelectionBoundsCounts(t *testing.T) { + m := Model{ + columns: []string{"a", "b", "c", "d"}, + visualMode: true, + visualStartRow: 1, + visualStartCol: 0, + selectedRow: 3, + selectedCol: 2, + } + + // Characterwise: rows 1..3, cols 0..2 → 3x3. + minR, maxR, minC, maxC := m.getSelectionBounds() + if rows, cols := maxR-minR+1, maxC-minC+1; rows != 3 || cols != 3 { + t.Errorf("charwise: got %dx%d, want 3x3", rows, cols) + } + + // Linewise: columns span all of them (4) regardless of start col. + m.visualLineMode = true + _, _, minC, maxC = m.getSelectionBounds() + if cols := maxC - minC + 1; cols != 4 { + t.Errorf("linewise cols: got %d, want 4 (all columns)", cols) + } +} + +func TestRenderFooter_SearchCounter(t *testing.T) { + m := Model{ + data: [][]string{{"a", "b"}, {"c", "d"}}, + columns: []string{"c1", "c2"}, + selectedRow: 0, + selectedCol: 0, + searchMatches: []CellPosition{{0, 0}, {1, 1}}, + cellMatchIdx: 0, + uiVisibility: config.UIVisibility{FooterCellContent: true, FooterStats: true}, + width: 80, + } + out := m.renderFooter() + if !strings.Contains(out, "1,1") { + t.Errorf("cursor restyle: missing '1,1' in:\n%s", out) + } + if !strings.Contains(out, "[1/2]") { + t.Errorf("search counter: missing '[1/2]' in:\n%s", out) + } + + // Frozen: cursor moved off any match, but the stored index is unchanged → + // counter still shows [2/2], never a plain total. + m.selectedRow, m.selectedCol = 5, 5 + m.cellMatchIdx = 1 + if out := m.renderFooter(); !strings.Contains(out, "[2/2]") { + t.Errorf("frozen counter: missing '[2/2]' in:\n%s", out) + } +} + +func TestRenderFooter_VisualCounter(t *testing.T) { + m := Model{ + data: [][]string{{"a", "b"}, {"c", "d"}, {"e", "f"}, {"g", "h"}, {"i", "j"}}, + columns: []string{"c1", "c2", "c3", "c4"}, + selectedRow: 1, + selectedCol: 1, + visualMode: true, + visualStartRow: 0, + visualStartCol: 0, + uiVisibility: config.UIVisibility{FooterCellContent: true}, + width: 80, + } + // Characterwise: rows 0..1 (2) x cols 0..1 (2). + if out := m.renderFooter(); !strings.Contains(out, "-- VISUAL 2x2 --") { + t.Errorf("charwise counter: missing '-- VISUAL 2x2 --' in:\n%s", out) + } + // Linewise: rows 0..1 (2) x all 4 columns. + m.visualLineMode = true + if out := m.renderFooter(); !strings.Contains(out, "-- V-LINE 2x4 --") { + t.Errorf("linewise counter: missing '-- V-LINE 2x4 --' in:\n%s", out) + } +} diff --git a/internal/table/model.go b/internal/table/model.go index 9958f29..d91ef65 100644 --- a/internal/table/model.go +++ b/internal/table/model.go @@ -46,15 +46,11 @@ type Model struct { shouldRerunQuery bool editedQuery string lastExecutedQuery string - displaySQL string cellWidth int detailViewMode bool detailViewContent string detailViewScroll int helpOverlayMode bool - isTablesList bool - onTableSelect func(string) tea.Cmd - selectedTableName string saveQueryCallback func(query db.Query) (db.Query, error) statusMessage string exportWaiting exportWaitingFormatState @@ -66,6 +62,8 @@ type Model struct { searchQuery string searchMatches []CellPosition searchColMatches []int + cellMatchIdx int + colMatchIdx int searchCursor int columnSearchMode bool } @@ -91,7 +89,7 @@ func New( columnTypes := make([]string, len(columns)) // Use passed column types from result set if available (works for JOINs, CTEs, etc.) - if passedColumnTypes != nil && len(passedColumnTypes) > 0 { + if len(passedColumnTypes) > 0 { // Use types from SQL driver's ColumnTypes() for i := range columns { if i < len(passedColumnTypes) { @@ -154,9 +152,8 @@ func New( shouldRerunQuery: false, editedQuery: "", cellWidth: columnWidth, - isTablesList: false, uiVisibility: visibility, - keyMap: keyMap, + keyMap: keyMap, searchMode: false, searchQuery: "", searchMatches: []CellPosition{}, @@ -219,13 +216,3 @@ func (m Model) calculateHeaderLines() int { return headerLines } - -func (m Model) SetTablesList(onSelect func(string) tea.Cmd) Model { - m.isTablesList = true - m.onTableSelect = onSelect - return m -} - -func (m Model) GetSelectedTableName() string { - return m.selectedTableName -} diff --git a/internal/table/navigation.go b/internal/table/navigation.go index 4ca55dc..b3931ef 100644 --- a/internal/table/navigation.go +++ b/internal/table/navigation.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/atotto/clipboard" tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" "github.com/eduardofuncao/squix/internal/editor" ) diff --git a/internal/table/save_query.go b/internal/table/save_query.go index ea9022e..1717bd6 100644 --- a/internal/table/save_query.go +++ b/internal/table/save_query.go @@ -134,16 +134,10 @@ func (m Model) handleSaveQueryComplete(msg saveQueryCompleteMsg) (tea.Model, tea } func (m Model) isNamedQuery() bool { - // Check if the query name indicates it's a saved query - // Named queries are NOT: , , , info commands name := m.currentQuery.Name if name == "" || name == "" || name == "" { return false } - // Info commands start with "info " - if len(name) >= 4 && name[:4] == "info " { - return false - } // If it has a valid ID and name, it's a saved query return m.currentQuery.Id > 0 && name != "" } diff --git a/internal/table/search.go b/internal/table/search.go index 00d761e..cfceab5 100644 --- a/internal/table/search.go +++ b/internal/table/search.go @@ -18,11 +18,30 @@ func shouldUseCaseSensitive(query string) bool { return false } +// cellMatchProgress and colMatchProgress returns the 1-based +// position of the match the cursor last landed on +func (m Model) cellMatchProgress() (current, total int) { + total = len(m.searchMatches) + if total == 0 { + return 0, 0 + } + return m.cellMatchIdx + 1, total +} + +func (m Model) colMatchProgress() (current, total int) { + total = len(m.searchColMatches) + if total == 0 { + return 0, 0 + } + return m.colMatchIdx + 1, total +} + func (m Model) startCellSearch() Model { m.searchMode = true m.columnSearchMode = false m.searchQuery = "" m.searchMatches = []CellPosition{} + m.cellMatchIdx = 0 m.searchCursor = 0 return m } @@ -32,6 +51,7 @@ func (m Model) startColumnSearch() Model { m.columnSearchMode = true m.searchQuery = "" m.searchColMatches = []int{} + m.colMatchIdx = 0 m.searchCursor = 0 return m } @@ -127,10 +147,11 @@ func (m Model) searchCells() Model { if len(m.searchMatches) > 0 { found := false - for _, match := range m.searchMatches { + for i, match := range m.searchMatches { if match.Row > m.selectedRow || (match.Row == m.selectedRow && match.Col > m.selectedCol) { m.selectedRow = match.Row m.selectedCol = match.Col + m.cellMatchIdx = i found = true break } @@ -140,6 +161,7 @@ func (m Model) searchCells() Model { firstMatch := m.searchMatches[0] m.selectedRow = firstMatch.Row m.selectedCol = firstMatch.Col + m.cellMatchIdx = 0 } if m.selectedRow < m.offsetY { @@ -179,9 +201,10 @@ func (m Model) searchColumnHeaders() Model { if len(m.searchColMatches) > 0 { found := false - for _, matchCol := range m.searchColMatches { + for i, matchCol := range m.searchColMatches { if matchCol > m.selectedCol { m.selectedCol = matchCol + m.colMatchIdx = i found = true break } @@ -189,6 +212,7 @@ func (m Model) searchColumnHeaders() Model { if !found { m.selectedCol = m.searchColMatches[0] + m.colMatchIdx = 0 } if m.selectedCol < m.offsetX { @@ -208,6 +232,8 @@ func (m Model) clearSearch() Model { m.searchQuery = "" m.searchMatches = []CellPosition{} m.searchColMatches = []int{} + m.cellMatchIdx = 0 + m.colMatchIdx = 0 return m } @@ -216,10 +242,11 @@ func (m Model) nextSearchMatch() Model { return m } - for _, match := range m.searchMatches { + for i, match := range m.searchMatches { if match.Row > m.selectedRow || (match.Row == m.selectedRow && match.Col > m.selectedCol) { m.selectedRow = match.Row m.selectedCol = match.Col + m.cellMatchIdx = i if m.selectedRow < m.offsetY { m.offsetY = m.selectedRow @@ -240,6 +267,7 @@ func (m Model) nextSearchMatch() Model { firstMatch := m.searchMatches[0] m.selectedRow = firstMatch.Row m.selectedCol = firstMatch.Col + m.cellMatchIdx = 0 if m.selectedRow < m.offsetY { m.offsetY = m.selectedRow @@ -267,6 +295,7 @@ func (m Model) prevSearchMatch() Model { if match.Row < m.selectedRow || (match.Row == m.selectedRow && match.Col < m.selectedCol) { m.selectedRow = match.Row m.selectedCol = match.Col + m.cellMatchIdx = i if m.selectedRow < m.offsetY { m.offsetY = m.selectedRow @@ -287,6 +316,7 @@ func (m Model) prevSearchMatch() Model { lastMatch := m.searchMatches[len(m.searchMatches)-1] m.selectedRow = lastMatch.Row m.selectedCol = lastMatch.Col + m.cellMatchIdx = len(m.searchMatches) - 1 if m.selectedRow < m.offsetY { m.offsetY = m.selectedRow @@ -309,9 +339,10 @@ func (m Model) nextColumnMatch() Model { return m } - for _, matchCol := range m.searchColMatches { + for i, matchCol := range m.searchColMatches { if matchCol > m.selectedCol { m.selectedCol = matchCol + m.colMatchIdx = i if m.selectedCol < m.offsetX { m.offsetX = m.selectedCol @@ -324,6 +355,7 @@ func (m Model) nextColumnMatch() Model { } m.selectedCol = m.searchColMatches[0] + m.colMatchIdx = 0 if m.selectedCol < m.offsetX { m.offsetX = m.selectedCol @@ -344,6 +376,7 @@ func (m Model) prevColumnMatch() Model { matchCol := m.searchColMatches[i] if matchCol < m.selectedCol { m.selectedCol = matchCol + m.colMatchIdx = i if m.selectedCol < m.offsetX { m.offsetX = m.selectedCol @@ -356,6 +389,7 @@ func (m Model) prevColumnMatch() Model { } m.selectedCol = m.searchColMatches[len(m.searchColMatches)-1] + m.colMatchIdx = len(m.searchColMatches) - 1 if m.selectedCol < m.offsetX { m.offsetX = m.selectedCol diff --git a/internal/table/table.go b/internal/table/table.go index 13c9e39..c54fa9e 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -46,23 +46,3 @@ func Render( } return finalModel.(Model), nil } - -func RenderTablesList( - columns []string, - data [][]string, - elapsed time.Duration, - conn db.DatabaseConnection, - query db.Query, - columnWidth int, - visibility config.UIVisibility, - keyMap *config.KeyMap, -) (Model, error) { - model := New(columns, nil, data, elapsed, conn, "", "", query, columnWidth, visibility, keyMap) - model.isTablesList = true - p := tea.NewProgram(model) - finalModel, err := p.Run() - if err != nil { - return model, err - } - return finalModel.(Model), nil -} diff --git a/internal/table/update.go b/internal/table/update.go index cd8ba22..1b13ddb 100644 --- a/internal/table/update.go +++ b/internal/table/update.go @@ -140,12 +140,6 @@ func (m Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.startExportAllFormatSelection() case config.ActionEnter: - if m.isTablesList { - if m.selectedRow >= 0 && m.selectedRow < m.numRows() { - m.selectedTableName = m.data[m.selectedRow][0] - return m, tea.Quit - } - } return m.showDetailView(), nil case config.ActionUpdate: diff --git a/internal/table/view.go b/internal/table/view.go index c0f1432..87ded62 100644 --- a/internal/table/view.go +++ b/internal/table/view.go @@ -204,10 +204,13 @@ func (m Model) renderFooter() string { displayValue := formatFooterPreview(currentCellValue, maxPreviewWidth) modeIndicator := "" - if m.visualLineMode { - modeIndicator = styles.SearchMatch.Render("-- V-LINE -- ") - } else if m.visualMode { - modeIndicator = styles.SearchMatch.Render("-- VISUAL -- ") + if m.visualLineMode || m.visualMode { + minR, maxR, minC, maxC := m.getSelectionBounds() + label := "VISUAL" + if m.visualLineMode { + label = "V-LINE" + } + modeIndicator = styles.SearchMatch.Render(fmt.Sprintf("-- %s %dx%d -- ", label, maxR-minR+1, maxC-minC+1)) } cellPreview = fmt.Sprintf("%s%s%s %s\n", modeIndicator, @@ -222,8 +225,16 @@ func (m Model) renderFooter() string { statsInfo = fmt.Sprintf("%s | %s | %s", styles.Faint.Render(fmt.Sprintf("%dx%d", m.numRows(), m.numCols())), styles.Faint.Render(fmt.Sprintf("In %.2fs", m.elapsed.Seconds())), - styles.Faint.Render(fmt.Sprintf("[%d/%d]", m.selectedRow+1, m.selectedCol+1)), + styles.Faint.Render(fmt.Sprintf("%d,%d", m.selectedRow+1, m.selectedCol+1)), ) + switch { + case !m.columnSearchMode && len(m.searchMatches) > 0: + cur, tot := m.cellMatchProgress() + statsInfo += " | " + styles.Faint.Render(matchLabel(cur, tot)) + case m.columnSearchMode && len(m.searchColMatches) > 0: + cur, tot := m.colMatchProgress() + statsInfo += " | " + styles.Faint.Render(matchLabel(cur, tot)) + } } // Build keymaps info (conditional) @@ -250,6 +261,10 @@ func (m Model) renderFooter() string { return fmt.Sprintf("\n%s%s%s", cellPreview, statsInfo, keymapsInfo) } +func matchLabel(cur, tot int) string { + return fmt.Sprintf("[%d/%d]", cur, tot) +} + func (m Model) getCellStyle(row, col int) lipgloss.Style { if m.blinkDeletedRow && m.deletedRow == row { return styles.TableDeleted @@ -448,14 +463,7 @@ func (m Model) renderHelpOverlay() string { } // Context adjustments - if m.isTablesList { - categories[2].binds = []keyBind{ - {km.DisplayKeys(config.ActionYank), "Yank (copy) selection"}, - {km.DisplayKeys(config.ActionExport), "Export selected cells"}, - {km.DisplayKeys(config.ActionExportAll), "Export all rows"}, - {km.DisplayKeys(config.ActionEnter), "Select table"}, - } - } else if m.tableName == "" { + if m.tableName == "" { categories[2].binds = []keyBind{ {km.DisplayKeys(config.ActionYank), "Yank (copy) selection"}, {km.DisplayKeys(config.ActionExport), "Export selected cells"}, diff --git a/test/integration/explore_test.go b/test/integration/explore_test.go index af4a94a..d9a1a4e 100644 --- a/test/integration/explore_test.go +++ b/test/integration/explore_test.go @@ -5,11 +5,11 @@ import ( "testing" ) -func TestExploreListTables(t *testing.T) { +func TestExploreOnelineListsTables(t *testing.T) { env := Setup(t) env.SeedDefaults() - stdout, _, exitCode := env.RunSquix("explore") + stdout, _, exitCode := env.RunSquix("explore", "--oneline") if exitCode != 0 { t.Fatalf("exit %d", exitCode) } @@ -18,6 +18,56 @@ func TestExploreListTables(t *testing.T) { } } +func TestExploreOnelineTablesFlag(t *testing.T) { + env := Setup(t) + env.SeedDefaults() + + stdout, _, exitCode := env.RunSquix("explore", "--oneline", "--tables") + if exitCode != 0 { + t.Fatalf("exit %d", exitCode) + } + if !strings.Contains(stdout, "users") { + t.Errorf("expected users:\n%s", stdout) + } +} + +func TestExploreOnelineViewsFlag(t *testing.T) { + env := Setup(t) + env.SeedData( + DefaultSchema+"CREATE VIEW adult_users AS SELECT id, name FROM users WHERE age >= 18;", + DefaultInserts, + ) + + tablesOut, _, exitCode := env.RunSquix("explore", "--oneline", "--tables") + if exitCode != 0 { + t.Fatalf("exit %d", exitCode) + } + if strings.Contains(tablesOut, "adult_users") { + t.Errorf("--tables should not list views:\n%s", tablesOut) + } + + viewsOut, _, exitCode := env.RunSquix("explore", "--oneline", "--views") + if exitCode != 0 { + t.Fatalf("exit %d", exitCode) + } + if !strings.Contains(viewsOut, "adult_users") { + t.Errorf("--views should list the view:\n%s", viewsOut) + } + if strings.Contains(viewsOut, "orders") { + t.Errorf("--views should not list base tables:\n%s", viewsOut) + } +} + +func TestTablesCommandRemoved(t *testing.T) { + env := Setup(t) + env.SeedDefaults() + + _, _, exitCode := env.RunSquix("tables") + if exitCode == 0 { + t.Errorf("squix tables should no longer exist") + } +} + func TestExplainTable(t *testing.T) { env := Setup(t) env.SeedDefaults() diff --git a/test/integration/helpers_test.go b/test/integration/helpers_test.go index 9fc49b9..482e0e6 100644 --- a/test/integration/helpers_test.go +++ b/test/integration/helpers_test.go @@ -114,6 +114,39 @@ func (e *TestEnv) SeedDefaults() { e.SeedData(DefaultSchema, DefaultInserts) } +// WriteConfig overwrites config.yaml in the env's config dir with the given YAML. +func (e *TestEnv) WriteConfig(yaml string) { + e.t.Helper() + path := filepath.Join(e.HomeDir, ".config", "squix", "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + e.t.Fatalf("write config: %v", err) + } +} + +// SeedDB creates a sqlite database at path and seeds it with the default schema +// and inserts. Used to give multiple {service}:{env} connections distinct files. +func (e *TestEnv) SeedDB(path string) { + e.t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + e.t.Fatalf("open sqlite: %v", err) + } + defer db.Close() + if _, err := db.Exec(DefaultSchema); err != nil { + e.t.Fatalf("exec schema: %v", err) + } + for _, ins := range DefaultInserts { + if _, err := db.Exec(ins); err != nil { + e.t.Fatalf("exec insert: %v\n%s", err, ins) + } + } +} + +// NewDBPath returns a unique sqlite file path under the test's temp dir. +func (e *TestEnv) NewDBPath(name string) string { + return filepath.Join(e.t.TempDir(), name) +} + func (e *TestEnv) RunSquix(args ...string) (stdout, stderr string, exitCode int) { e.t.Helper() diff --git a/test/integration/library_test.go b/test/integration/library_test.go new file mode 100644 index 0000000..53e7ac7 --- /dev/null +++ b/test/integration/library_test.go @@ -0,0 +1,227 @@ +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// multiConnConfig builds a config.yaml string with the given connections +// (name -> sqlite conn_string) and the given current connection. Query groups +// are intentionally omitted so tests exercise the live code path. +func multiConnConfig(conns map[string]string, current string) string { + var b strings.Builder + b.WriteString("current_connection: " + current + "\nconnections:\n") + for name, cs := range conns { + b.WriteString(" " + name + ":\n") + b.WriteString(" name: " + name + "\n") + b.WriteString(" db_type: sqlite\n") + b.WriteString(" conn_string: " + cs + "\n") + } + return b.String() +} + +func TestSharedLibraryAcrossEnvs(t *testing.T) { + env := Setup(t) + devPath := env.NewDBPath("dev.db") + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": devPath, + "ecommerce:prod": env.NewDBPath("prod.db"), + }, "ecommerce:dev")) + + if _, _, code := env.RunSquix("add", "list_users", "SELECT * FROM users"); code != 0 { + t.Fatal("add failed") + } + + // Switch to prod (sibling). The query added on dev must be visible. + env.RunSquix("switch", "ecommerce:prod") + stdout, _, code := env.RunSquix("list", "queries") + if code != 0 { + t.Fatalf("list queries failed: %s", stdout) + } + if !strings.Contains(stdout, "list_users") { + t.Errorf("prod does not see query shared from dev:\n%s", stdout) + } +} + +func TestStandalonePrivateLibrary(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + "analytics": env.NewDBPath("analytics.db"), + }, "ecommerce:dev")) + + env.RunSquix("add", "list_users", "SELECT * FROM users") + + env.RunSquix("switch", "analytics") + stdout, _, _ := env.RunSquix("list", "queries") + if strings.Contains(stdout, "list_users") { + t.Errorf("standalone connection leaked query from ecommerce group:\n%s", stdout) + } +} + +func TestLastQueryClearedOnSwitch(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + "ecommerce:prod": env.NewDBPath("prod.db"), + }, "ecommerce:dev")) + + // Run a query on dev → writes last-query.sql via the execute chokepoint. + if _, _, code := env.RunSquix("run", "SELECT 1", "-f", "csv"); code != 0 { + t.Fatal("run on dev failed") + } + + // Switching connections clears last-query.sql (ephemeral, no longer stored + // per-connection in config.yaml). + env.RunSquix("switch", "ecommerce:prod") + + // --last on prod must fail: switching wiped the last query. + _, stderr, code := env.RunSquix("run", "--last") + if code == 0 { + t.Fatalf("run --last after switch should fail; stderr: %s", stderr) + } + if !strings.Contains(strings.ToLower(stderr), "no last query") { + t.Errorf("expected 'no last query' error, got stderr: %s", stderr) + } +} + +func TestListConnections_AnnotatesGroup(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + "analytics": env.NewDBPath("analytics.db"), + }, "ecommerce:dev")) + + stdout, _, code := env.RunSquix("list", "connections") + if code != 0 { + t.Fatalf("list connections failed: %s", stdout) + } + // The ecommerce:dev line shows its shared group key "ecommerce". + devLine := "" + for _, line := range strings.Split(stdout, "\n") { + if strings.Contains(line, "ecommerce:dev") { + devLine = line + break + } + } + if devLine == "" { + t.Fatalf("ecommerce:dev not listed:\n%s", stdout) + } + // The group key appears (after the db_type). Count "ecommerce" occurrences + // on the line: name "ecommerce:dev" is one, the annotation is another. + if strings.Count(devLine, "ecommerce") < 2 { + t.Errorf("expected group annotation on dev line, got: %q", devLine) + } +} + +func TestInitSibling_SeesExistingGroup(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + }, "ecommerce:dev")) + + env.RunSquix("add", "list_users", "SELECT * FROM users") + + // init a sibling env; its sqlite file is created on connect. + prodPath := env.NewDBPath("prod.db") + if _, _, code := env.RunSquix("init", "ecommerce:prod", prodPath); code != 0 { + t.Fatal("init sibling failed") + } + + // init sets current to the new connection. + stdout, _, code := env.RunSquix("list", "queries") + if code != 0 { + t.Fatalf("list queries failed: %s", stdout) + } + if !strings.Contains(stdout, "list_users") { + t.Errorf("newly-init'd sibling does not see the shared group:\n%s", stdout) + } +} + +func TestRemoveConnection_KeepsGroupWhenSiblingRemains(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + "ecommerce:prod": env.NewDBPath("prod.db"), + }, "ecommerce:dev")) + + env.RunSquix("add", "list_users", "SELECT * FROM users") + // Remove dev; prod remains and should keep the shared group. + if _, _, code := env.RunSquixWithStdin("y\n", "remove", "-c", "ecommerce:dev"); code != 0 { + t.Fatal("remove dev failed") + } + + env.RunSquix("switch", "ecommerce:prod") + stdout, _, code := env.RunSquix("list", "queries") + if code != 0 { + t.Fatalf("list queries on prod failed: %s", stdout) + } + if !strings.Contains(stdout, "list_users") { + t.Errorf("removing a sibling dropped the shared group:\n%s", stdout) + } +} + +func TestRemoveConnection_GCOrphanGroup(t *testing.T) { + env := Setup(t) + env.WriteConfig(multiConnConfig(map[string]string{ + "ecommerce:dev": env.NewDBPath("dev.db"), + }, "ecommerce:dev")) + + env.RunSquix("add", "list_users", "SELECT * FROM users") + + grpFile := filepath.Join(env.HomeDir, ".config", "squix", "queries", "ecommerce.sql") + if _, err := os.Stat(grpFile); err != nil { + t.Fatalf("group file not created after add: %v", err) + } + if _, _, code := env.RunSquixWithStdin("y\n", "remove", "-c", "ecommerce:dev"); code != 0 { + t.Fatal("remove dev failed") + } + + // The orphaned group file must be removed with its last connection. + if _, err := os.Stat(grpFile); !os.IsNotExist(err) { + t.Errorf("orphaned group file not removed after removing last member: %v", err) + } +} + +func TestMigration_LegacyConfigUpgrades(t *testing.T) { + env := Setup(t) + legacy := `current_connection: onlydb +connections: + onlydb: + name: onlydb + db_type: sqlite + conn_string: ` + env.NewDBPath("only.db") + ` + queries: + legacy_q: + name: legacy_q + id: 1 + sql: SELECT 1 +` + env.WriteConfig(legacy) + + // Any saving command triggers load+migrate+save. + if _, _, code := env.RunSquix("add", "new_q", "SELECT 2"); code != 0 { + t.Fatal("add on legacy config failed") + } + + // Legacy queries migrate to a per-group .sql file. + grpFile := filepath.Join(env.HomeDir, ".config", "squix", "queries", "onlydb.sql") + data, err := os.ReadFile(grpFile) + if err != nil { + t.Fatalf("legacy queries not migrated to %s: %v", grpFile, err) + } + if !strings.Contains(string(data), "legacy_q") { + t.Errorf("legacy query lost during migration:\n%s", data) + } + + // config.yaml must no longer carry inline per-connection queries. + cfgData, err := os.ReadFile(filepath.Join(env.HomeDir, ".config", "squix", "config.yaml")) + if err != nil { + t.Fatalf("read config: %v", err) + } + if strings.Contains(string(cfgData), "queries:") { + t.Errorf("inline per-connection queries still present after migration:\n%s", cfgData) + } +}
%s%s
%s%s