├── .gitignore ├── Makefile ├── README.md ├── config.json.example └── prequel.go /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | prequel 3 | prequel.sql 4 | outfile 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: build 2 | 3 | build: 4 | go build prequel.go 5 | 6 | install: 7 | mkdir -p ${DESTDIR}/usr/bin 8 | cp prequel ${DESTDIR}/usr/bin/prequel 9 | 10 | install-dev: 11 | mkdir -p ${DESTDIR}/usr/bin 12 | ln -s `pwd`/prequel ${DESTDIR}/usr/bin/prequel 13 | 14 | uninstall: 15 | rm ${DESTDIR}/usr/bin/prequel 16 | 17 | clean: 18 | rm prequel 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | prequel 2 | ======= 3 | 4 | A MySQL query editor with syntax highlighting for the terminal. 5 | 6 | ![Prequel screenshot](https://s3.amazonaws.com/briansteffens/prequel2.png) 7 | 8 | # Downloading and compiling 9 | 10 | You'll need git and go installed. Then: 11 | 12 | ```bash 13 | git clone https://github.com/briansteffens/prequel 14 | cd prequel 15 | make 16 | ``` 17 | 18 | The binary will now be located at `./prequel`. 19 | 20 | # Installation 21 | 22 | To do a normal install, do this after compiling: 23 | 24 | ```bash 25 | sudo make install 26 | ``` 27 | 28 | This will install the binary to `/usr/bin/prequel`. 29 | 30 | # Uninstallation 31 | 32 | Do this to uninstall the binary: 33 | 34 | ```bash 35 | sudo make uninstall 36 | ``` 37 | 38 | # Connecting to a database 39 | 40 | Prequel checks the current directory for a config.json file and uses that to 41 | figure out which database connection settings to use. Copy the example file 42 | and customize it to fit your environment: 43 | 44 | ```bash 45 | cp config.json.example config.json 46 | vim config.json 47 | ``` 48 | 49 | Once the configuration is done, run the program: 50 | 51 | ```bash 52 | prequel 53 | ``` 54 | 55 | Prequel is divided into two sections: a query editor on top and a results view 56 | on the bottom. Use the tab key to switch between them. 57 | 58 | # Using the query editor 59 | 60 | The query editor has vim-inspired shortcuts. There are two modes: command and 61 | insert. Command is the default. 62 | 63 | Here are some shortcuts available in command mode: 64 | 65 | | Shortcut | Action | 66 | |-------------|---------------------------------------------------------------| 67 | | F5 | Run the current query | 68 | | i | Enter insert mode | 69 | | Tab | Switch focus to the results view | 70 | | h | Move the cursor left | 71 | | l | Move the cursor right | 72 | | j | Move the cursor down | 73 | | k | Move the cursor up | 74 | | Left arrow | Move the cursor left | 75 | | Right arrow | Move the cursor right | 76 | | Down arrow | Move the cursor down | 77 | | Up arrow | Move the cursor up | 78 | | 0 | Move to the beginning of the current line | 79 | | A | Move to the end of the current line and enter insert mode | 80 | | o | Create a new line after the current line and enter insert mode| 81 | | w | Advance to the next word | 82 | | b | Move to the previous word | 83 | | x | Delete the current character | 84 | | gg | Move to the first character in the first line | 85 | | G | Move to the last character in the last line | 86 | | dd | Delete the current line | 87 | | cw | Delete the current word and enter insert mode | 88 | | Home | Move to the beginning of the current line | 89 | | End | Move to the end of the current line | 90 | | Ctrl+C | Exit the program | 91 | 92 | While in insert mode, you can type normally. The following shortcuts are 93 | available: 94 | 95 | | Shortcut | Action | 96 | |-------------|---------------------------------------------------------------| 97 | | F5 | Run the current query | 98 | | Escape | Switch back to command mode | 99 | | Home | Move to the beginning of the current line | 100 | | End | Move to the end of the current line | 101 | | Ctrl+C | Exit the program | 102 | 103 | In the detail view, the following shortcuts are available: 104 | 105 | | Shortcut | Action | 106 | |-------------|---------------------------------------------------------------| 107 | | Tab | Switch focus to the query editor | 108 | | Home | Move to the first column in the current row | 109 | | End | Move to the last column in the current row | 110 | | Page Up | Move up one page of rows | 111 | | Page Down | Move down one page of rows | 112 | | h | Move the selection to the left one column | 113 | | l | Move the selection to the right one column | 114 | | j | Move the selection down one row | 115 | | k | Move the selection up one row | 116 | | Arrow Keys | Scroll the viewport without changing the selection | 117 | | Ctrl+C | Exit the program | 118 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "driver": "mysql", 3 | "host": "", 4 | "port": 3306, 5 | "user": "root", 6 | "password": "", 7 | "database": "litgraph" 8 | } 9 | -------------------------------------------------------------------------------- /prequel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "encoding/json" 8 | "database/sql" 9 | "github.com/nsf/termbox-go" 10 | "github.com/briansteffens/escapebox" 11 | "github.com/briansteffens/tui" 12 | _ "github.com/go-sql-driver/mysql" 13 | ) 14 | 15 | const minColumnWidth int = 5 16 | const maxColumnWidth int = 25 17 | 18 | const cursorStatementColor termbox.Attribute = termbox.Attribute(237) 19 | 20 | const tempSqlFile string = "prequel.sql" 21 | 22 | type Connection struct { 23 | Driver string `json:"driver"` 24 | Host string `json:"host"` 25 | Port int `json:"port"` 26 | User string `json:"user"` 27 | Password string `json:"password"` 28 | Database string `json:"database"` 29 | } 30 | 31 | type Statement struct { 32 | start int 33 | length int 34 | } 35 | 36 | var db *sql.DB 37 | var editor tui.EditBox 38 | var results tui.DetailView 39 | var container tui.Container 40 | var status tui.Label 41 | var statements []Statement 42 | var statement Statement 43 | 44 | func resizeHandler() { 45 | editor.Bounds.Width = container.Width 46 | editor.Bounds.Height = container.Height / 2 47 | 48 | results.Bounds.Top = editor.Bounds.Height 49 | results.Bounds.Width = container.Width 50 | results.Bounds.Height = container.Height - editor.Bounds.Height - 1 51 | 52 | status.Bounds.Top = results.Bounds.Bottom() + 1 53 | status.Bounds.Width = container.Width 54 | } 55 | 56 | func connect(conn Connection) (*sql.DB, error) { 57 | dsn := conn.User 58 | 59 | if conn.Password != "" { 60 | dsn += ":" + conn.Password 61 | } 62 | 63 | if dsn != "" { 64 | dsn += "@" 65 | } 66 | 67 | dsn += fmt.Sprintf("tcp(%s:%d)", conn.Host, conn.Port) 68 | 69 | if conn.Database != "" { 70 | dsn += "/" + conn.Database 71 | } 72 | 73 | return sql.Open(conn.Driver, dsn) 74 | } 75 | 76 | func cursorInWhichStatement(cur int, ss []Statement) (Statement, error) { 77 | for _, s := range ss { 78 | if cur > s.start + s.length - 1 { 79 | continue 80 | } 81 | 82 | return s, nil 83 | } 84 | 85 | // Default to last statement if there is one 86 | if len(ss) > 0 { 87 | return ss[len(ss) - 1], nil 88 | } 89 | 90 | return Statement {}, errors.New("Cursor not in statement") 91 | } 92 | 93 | func editorTextChanged(e *tui.EditBox) { 94 | err := ioutil.WriteFile(tempSqlFile, []byte(e.GetText()), 0644) 95 | if err != nil { 96 | panic(err) 97 | } 98 | 99 | lineHighlighter(e) 100 | } 101 | 102 | func lineHighlighter(e *tui.EditBox) { 103 | var cur, next *tui.Char 104 | 105 | statements = []Statement {} 106 | statementStart := 0 107 | 108 | chars := e.AllChars() 109 | 110 | for i := 0; i <= len(chars); i++ { 111 | cur = next 112 | 113 | if i < len(chars) { 114 | next = chars[i] 115 | } else { 116 | next = nil 117 | } 118 | 119 | // Skip first iteration because cur won't be set yet. 120 | if cur == nil { 121 | continue 122 | } 123 | 124 | // Statements end at unquoted semi-colons and EOF 125 | if next == nil || 126 | cur.Quote == tui.QuoteNone && cur.Char == ';' { 127 | newStatement := Statement { 128 | start: statementStart, 129 | length: i - statementStart, 130 | } 131 | 132 | statementStart = i 133 | 134 | // Statements should include a trailing newline if 135 | // present. 136 | if next != nil && next.Char == '\n' { 137 | newStatement.length++ 138 | statementStart++ 139 | } 140 | 141 | statements = append(statements, newStatement) 142 | } 143 | } 144 | 145 | statement, _ = cursorInWhichStatement(e.GetCursor(), statements) 146 | 147 | for i := 0; i < len(chars); i++ { 148 | if i >= statement.start && 149 | i < statement.start + statement.length { 150 | chars[i].Bg = cursorStatementColor 151 | } else { 152 | chars[i].Bg = termbox.ColorBlack 153 | } 154 | } 155 | } 156 | 157 | func handleContainerEvent(c *tui.Container, ev escapebox.Event) bool { 158 | if ev.Type == termbox.EventKey && ev.Key == termbox.KeyF5 { 159 | runQuery() 160 | return true 161 | } 162 | 163 | return false 164 | } 165 | 166 | func runQuery() { 167 | results.Reset() 168 | status.Text = "" 169 | 170 | query := "" 171 | for i := statement.start; i < statement.start + statement.length; i++ { 172 | ch, err := editor.GetChar(i) 173 | if err != nil { 174 | panic(err) 175 | } 176 | query += string(ch.Char) 177 | } 178 | 179 | res, err := db.Query(query) 180 | if err != nil { 181 | status.Text = fmt.Sprintf("%s", err) 182 | return 183 | } 184 | defer res.Close() 185 | 186 | columnNames, err := res.Columns() 187 | if err != nil { 188 | panic(err) 189 | } 190 | 191 | values := make([]interface{}, len(columnNames)) 192 | valuePointers := make([]interface{}, len(columnNames)) 193 | 194 | for i := 0; i < len(columnNames); i++ { 195 | valuePointers[i] = &values[i] 196 | } 197 | 198 | rows := make([][]string, 0) 199 | 200 | for res.Next() { 201 | if err := res.Scan(valuePointers...); err != nil { 202 | panic(err) 203 | } 204 | 205 | row := make([]string, len(columnNames)) 206 | 207 | for i := 0; i < len(columnNames); i++ { 208 | val := "null" 209 | if values[i] != nil { 210 | val = fmt.Sprintf("%s", values[i]) 211 | } 212 | row[i] = val 213 | } 214 | 215 | rows = append(rows, row) 216 | } 217 | 218 | columns := make([]tui.Column, len(columnNames)) 219 | 220 | for i := 0; i < len(columnNames); i++ { 221 | columns[i].Name = columnNames[i] 222 | 223 | width := len(columns[i].Name) 224 | 225 | for _, row := range rows { 226 | if len(row[i]) > width { 227 | width = len(row[i]) 228 | } 229 | } 230 | 231 | width++ 232 | 233 | if width < minColumnWidth { 234 | width = minColumnWidth 235 | } 236 | 237 | if width > maxColumnWidth { 238 | width = maxColumnWidth 239 | } 240 | 241 | columns[i].Width = width 242 | } 243 | 244 | results.Columns = columns 245 | results.Rows = rows 246 | } 247 | 248 | func main() { 249 | configBytes, err := ioutil.ReadFile("config.json") 250 | if err != nil { 251 | panic(err) 252 | } 253 | 254 | connection := Connection{} 255 | err = json.Unmarshal(configBytes, &connection) 256 | if err != nil { 257 | fmt.Println("Error: config.json, invalid json") 258 | panic(err) 259 | } 260 | 261 | if connection.Driver == "" { 262 | fmt.Println("Error: config.json is missing the 'driver' " + 263 | "field"); 264 | return; 265 | } 266 | 267 | if connection.Database == "" { 268 | fmt.Println("Error: config.json is missing the 'database' " + 269 | "field"); 270 | return; 271 | } 272 | 273 | db, err = connect(connection) 274 | if err != nil { 275 | panic(err) 276 | } 277 | defer db.Close() 278 | 279 | err = db.Ping() 280 | if err != nil { 281 | panic(err) 282 | } 283 | 284 | tempSql := "show tables;" 285 | tempSqlBytes, err := ioutil.ReadFile(tempSqlFile) 286 | if err == nil { 287 | tempSql = string(tempSqlBytes); 288 | } 289 | 290 | tui.Init() 291 | defer tui.Close() 292 | 293 | editor = tui.EditBox { 294 | Highlighter: tui.BasicHighlighter, 295 | Dialect: tui.DialectMySQL, 296 | OnTextChanged: editorTextChanged, 297 | OnCursorMoved: lineHighlighter, 298 | } 299 | editor.SetText(tempSql) 300 | 301 | results = tui.DetailView { 302 | Columns: []tui.Column {}, 303 | Rows: [][]string {}, 304 | RowBg: termbox.Attribute(0), 305 | RowBgAlt: termbox.Attribute(236), 306 | SelectedBg: termbox.Attribute(22), 307 | } 308 | 309 | status = tui.Label { 310 | } 311 | 312 | container = tui.Container { 313 | Controls: []tui.Control {&results, &editor, &status}, 314 | ResizeHandler: resizeHandler, 315 | KeyBindingExit: tui.KeyBinding { Key: termbox.KeyCtrlC }, 316 | KeyBindingFocusNext: tui.KeyBinding { Key: termbox.KeyTab }, 317 | KeyBindingFocusPrevious: tui.KeyBinding { 318 | Seq: tui.SeqShiftTab, 319 | }, 320 | HandleEvent: handleContainerEvent, 321 | } 322 | 323 | tui.MainLoop(&container) 324 | } 325 | --------------------------------------------------------------------------------