github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/interactive/interactive_client.go (about) 1 package interactive 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "log" 9 "os" 10 "os/signal" 11 "strings" 12 "sync" 13 "time" 14 15 "github.com/alecthomas/chroma/formatters" 16 "github.com/alecthomas/chroma/lexers" 17 "github.com/alecthomas/chroma/styles" 18 "github.com/c-bata/go-prompt" 19 "github.com/jackc/pgx/v5/pgconn" 20 "github.com/spf13/viper" 21 "github.com/turbot/go-kit/helpers" 22 "github.com/turbot/steampipe/pkg/cmdconfig" 23 "github.com/turbot/steampipe/pkg/connection_sync" 24 "github.com/turbot/steampipe/pkg/constants" 25 "github.com/turbot/steampipe/pkg/db/db_common" 26 "github.com/turbot/steampipe/pkg/display" 27 "github.com/turbot/steampipe/pkg/error_helpers" 28 "github.com/turbot/steampipe/pkg/interactive/metaquery" 29 "github.com/turbot/steampipe/pkg/query" 30 "github.com/turbot/steampipe/pkg/query/queryhistory" 31 "github.com/turbot/steampipe/pkg/statushooks" 32 "github.com/turbot/steampipe/pkg/steampipeconfig" 33 "github.com/turbot/steampipe/pkg/steampipeconfig/modconfig" 34 "github.com/turbot/steampipe/pkg/utils" 35 "github.com/turbot/steampipe/pkg/version" 36 ) 37 38 type AfterPromptCloseAction int 39 40 const ( 41 AfterPromptCloseExit AfterPromptCloseAction = iota 42 AfterPromptCloseRestart 43 ) 44 45 // InteractiveClient is a wrapper over a LocalClient and a Prompt to facilitate interactive query prompt 46 type InteractiveClient struct { 47 initData *query.InitData 48 promptResult *RunInteractivePromptResult 49 interactiveBuffer []string 50 interactivePrompt *prompt.Prompt 51 interactiveQueryHistory *queryhistory.QueryHistory 52 autocompleteOnEmpty bool 53 // the cancellation function for the active query - may be nil 54 // NOTE: should ONLY be called by cancelActiveQueryIfAny 55 cancelActiveQuery context.CancelFunc 56 cancelPrompt context.CancelFunc 57 58 // channel used internally to pass the initialisation result 59 initResultChan chan *db_common.InitResult 60 // flag set when initialisation is complete (with or without errors) 61 initialisationComplete bool 62 afterClose AfterPromptCloseAction 63 // lock while execution is occurring to avoid errors/warnings being shown 64 executionLock sync.Mutex 65 // the schema metadata - this is loaded asynchronously during init 66 schemaMetadata *db_common.SchemaMetadata 67 highlighter *Highlighter 68 // hidePrompt is used to render a blank as the prompt prefix 69 hidePrompt bool 70 71 suggestions *autoCompleteSuggestions 72 } 73 74 func getHighlighter(theme string) *Highlighter { 75 return newHighlighter( 76 lexers.Get("sql"), 77 formatters.Get("terminal256"), 78 styles.Native, 79 ) 80 } 81 82 func newInteractiveClient(ctx context.Context, initData *query.InitData, result *RunInteractivePromptResult) (*InteractiveClient, error) { 83 interactiveQueryHistory, err := queryhistory.New() 84 if err != nil { 85 return nil, err 86 } 87 c := &InteractiveClient{ 88 initData: initData, 89 promptResult: result, 90 interactiveQueryHistory: interactiveQueryHistory, 91 interactiveBuffer: []string{}, 92 autocompleteOnEmpty: false, 93 initResultChan: make(chan *db_common.InitResult, 1), 94 highlighter: getHighlighter(viper.GetString(constants.ArgTheme)), 95 suggestions: newAutocompleteSuggestions(), 96 } 97 98 // asynchronously wait for init to complete 99 // we start this immediately rather than lazy loading as we want to handle errors asap 100 go c.readInitDataStream(ctx) 101 102 return c, nil 103 } 104 105 // InteractivePrompt starts an interactive prompt and return 106 func (c *InteractiveClient) InteractivePrompt(parentContext context.Context) { 107 // start a cancel handler for the interactive client - this will call activeQueryCancelFunc if it is set 108 // (registered when we call createQueryContext) 109 quitChannel := c.startCancelHandler() 110 111 // create a cancel context for the prompt - this will set c.cancelPrompt 112 ctx := c.createPromptContext(parentContext) 113 114 defer func() { 115 if r := recover(); r != nil { 116 error_helpers.ShowError(ctx, helpers.ToError(r)) 117 } 118 // close up the SIGINT channel so that the receiver goroutine can quit 119 quitChannel <- true 120 close(quitChannel) 121 122 // cleanup the init data to ensure any services we started are stopped 123 c.initData.Cleanup(ctx) 124 125 // close the result stream 126 // this needs to be the last thing we do, 127 // as the query result display code will exit once the result stream is closed 128 c.promptResult.Streamer.Close() 129 }() 130 131 statushooks.Message( 132 ctx, 133 fmt.Sprintf("Welcome to Steampipe v%s", version.SteampipeVersion.String()), 134 fmt.Sprintf("For more information, type %s", constants.Bold(".help")), 135 ) 136 137 // run the prompt in a goroutine, so we can also detect async initialisation errors 138 promptResultChan := make(chan struct{}, 1) 139 c.runInteractivePromptAsync(ctx, promptResultChan) 140 141 // select results 142 for { 143 select { 144 case initResult := <-c.initResultChan: 145 c.handleInitResult(ctx, initResult) 146 // if there was an error, handleInitResult will shut down the prompt 147 // - we must wait for it to shut down and not return immediately 148 149 case <-promptResultChan: 150 // persist saved history 151 //nolint:golint,errcheck // worst case is history is not persisted - not a failure 152 c.interactiveQueryHistory.Persist() 153 // check post-close action 154 if c.afterClose == AfterPromptCloseExit { 155 // clear prompt so any messages/warnings can be displayed without the prompt 156 c.hidePrompt = true 157 c.interactivePrompt.ClearLine() 158 return 159 } 160 // create new context with a cancellation func 161 ctx = c.createPromptContext(parentContext) 162 // now run it again 163 c.runInteractivePromptAsync(ctx, promptResultChan) 164 } 165 } 166 } 167 168 // ClosePrompt cancels the running prompt, setting the action to take after close 169 func (c *InteractiveClient) ClosePrompt(afterClose AfterPromptCloseAction) { 170 c.afterClose = afterClose 171 c.cancelPrompt() 172 } 173 174 // retrieve both the raw query result and a sanitised version in list form 175 func (c *InteractiveClient) loadSchema() error { 176 utils.LogTime("db_client.loadSchema start") 177 defer utils.LogTime("db_client.loadSchema end") 178 179 // load these schemas 180 // in a background context, since we are not running in a context - but GetSchemaFromDB needs one 181 metadata, err := c.client().GetSchemaFromDB(context.Background()) 182 if err != nil { 183 return fmt.Errorf("failed to load schemas: %s", err.Error()) 184 } 185 186 c.schemaMetadata = metadata 187 return nil 188 } 189 190 func (c *InteractiveClient) runInteractivePromptAsync(ctx context.Context, promptResultChan chan struct{}) { 191 go func() { 192 c.runInteractivePrompt(ctx) 193 promptResultChan <- struct{}{} 194 }() 195 } 196 197 func (c *InteractiveClient) runInteractivePrompt(ctx context.Context) { 198 defer func() { 199 // this is to catch the PANIC that gets raised by 200 // the executor of go-prompt 201 // 202 // We need to do it this way, since there is no 203 // clean way to reload go-prompt so that we can 204 // populate the history stack 205 // 206 if r := recover(); r != nil { 207 // show the panic and restart the prompt 208 error_helpers.ShowError(ctx, helpers.ToError(r)) 209 c.afterClose = AfterPromptCloseRestart 210 c.hidePrompt = false 211 return 212 } 213 }() 214 215 callExecutor := func(line string) { 216 c.executor(ctx, line) 217 } 218 completer := func(d prompt.Document) []prompt.Suggest { 219 return c.queryCompleter(d) 220 } 221 c.interactivePrompt = prompt.New( 222 callExecutor, 223 completer, 224 prompt.OptionTitle("steampipe interactive client "), 225 prompt.OptionLivePrefix(func() (prefix string, useLive bool) { 226 prefix = "> " 227 useLive = true 228 if len(c.interactiveBuffer) > 0 { 229 prefix = ">> " 230 } 231 if c.hidePrompt { 232 prefix = "" 233 } 234 return 235 }), 236 prompt.OptionFormatter(c.highlighter.Highlight), 237 prompt.OptionHistory(c.interactiveQueryHistory.Get()), 238 prompt.OptionInputTextColor(prompt.DefaultColor), 239 prompt.OptionPrefixTextColor(prompt.DefaultColor), 240 prompt.OptionMaxSuggestion(20), 241 // Known Key Bindings 242 prompt.OptionAddKeyBind(prompt.KeyBind{ 243 Key: prompt.ControlC, 244 Fn: func(b *prompt.Buffer) { c.breakMultilinePrompt(b) }, 245 }), 246 prompt.OptionAddKeyBind(prompt.KeyBind{ 247 Key: prompt.ControlD, 248 Fn: func(b *prompt.Buffer) { 249 if b.Text() == "" { 250 c.ClosePrompt(AfterPromptCloseExit) 251 } 252 }, 253 }), 254 prompt.OptionAddKeyBind(prompt.KeyBind{ 255 Key: prompt.Tab, 256 Fn: func(b *prompt.Buffer) { 257 if len(b.Text()) == 0 { 258 c.autocompleteOnEmpty = true 259 } else { 260 c.autocompleteOnEmpty = false 261 } 262 }, 263 }), 264 prompt.OptionAddKeyBind(prompt.KeyBind{ 265 Key: prompt.Escape, 266 Fn: func(b *prompt.Buffer) { 267 if len(b.Text()) == 0 { 268 c.autocompleteOnEmpty = false 269 } 270 }, 271 }), 272 prompt.OptionAddKeyBind(prompt.KeyBind{ 273 Key: prompt.ShiftLeft, 274 Fn: prompt.GoLeftChar, 275 }), 276 prompt.OptionAddKeyBind(prompt.KeyBind{ 277 Key: prompt.ShiftRight, 278 Fn: prompt.GoRightChar, 279 }), 280 prompt.OptionAddKeyBind(prompt.KeyBind{ 281 Key: prompt.ShiftUp, 282 Fn: func(b *prompt.Buffer) { /*ignore*/ }, 283 }), 284 prompt.OptionAddKeyBind(prompt.KeyBind{ 285 Key: prompt.ShiftDown, 286 Fn: func(b *prompt.Buffer) { /*ignore*/ }, 287 }), 288 // Opt+LeftArrow 289 prompt.OptionAddASCIICodeBind(prompt.ASCIICodeBind{ 290 ASCIICode: constants.OptLeftArrowASCIICode, 291 Fn: prompt.GoLeftWord, 292 }), 293 // Opt+RightArrow 294 prompt.OptionAddASCIICodeBind(prompt.ASCIICodeBind{ 295 ASCIICode: constants.OptRightArrowASCIICode, 296 Fn: prompt.GoRightWord, 297 }), 298 // Alt+LeftArrow 299 prompt.OptionAddASCIICodeBind(prompt.ASCIICodeBind{ 300 ASCIICode: constants.AltLeftArrowASCIICode, 301 Fn: prompt.GoLeftWord, 302 }), 303 // Alt+RightArrow 304 prompt.OptionAddASCIICodeBind(prompt.ASCIICodeBind{ 305 ASCIICode: constants.AltRightArrowASCIICode, 306 Fn: prompt.GoRightWord, 307 }), 308 prompt.OptionBufferPreHook(func(input string) (modifiedInput string, ignore bool) { 309 // if this is not WSL, return as-is 310 if !utils.IsWSL() { 311 return input, false 312 } 313 return cleanBufferForWSL(input) 314 }), 315 ) 316 // set this to a default 317 c.autocompleteOnEmpty = false 318 c.interactivePrompt.RunCtx(ctx) 319 320 return 321 } 322 323 func cleanBufferForWSL(s string) (string, bool) { 324 b := []byte(s) 325 // in WSL, 'Alt' combo-characters are denoted by [27, ASCII of character] 326 // if we get a combination which has 27 as prefix - we should ignore it 327 // this is inline with other interactive clients like pgcli 328 if len(b) > 1 && bytes.HasPrefix(b, []byte{byte(27)}) { 329 // ignore it 330 return "", true 331 } 332 return string(b), false 333 } 334 335 func (c *InteractiveClient) breakMultilinePrompt(buffer *prompt.Buffer) { 336 c.interactiveBuffer = []string{} 337 } 338 339 func (c *InteractiveClient) executor(ctx context.Context, line string) { 340 // take an execution lock, so that errors and warnings don't show up while 341 // we are underway 342 c.executionLock.Lock() 343 defer c.executionLock.Unlock() 344 345 // set afterClose to restart - is we are exiting the metaquery will set this to AfterPromptCloseExit 346 c.afterClose = AfterPromptCloseRestart 347 348 line = strings.TrimSpace(line) 349 350 resolvedQuery := c.getQuery(ctx, line) 351 if resolvedQuery == nil { 352 // we failed to resolve a query, or are in the middle of a multi-line entry 353 // restart the prompt, DO NOT clear the interactive buffer 354 c.restartInteractiveSession() 355 return 356 } 357 358 // we successfully retrieved a query 359 360 // create a context for the execution of the query 361 queryCtx := c.createQueryContext(ctx) 362 363 if resolvedQuery.IsMetaQuery { 364 c.hidePrompt = true 365 c.interactivePrompt.Render() 366 367 if err := c.executeMetaquery(queryCtx, resolvedQuery.ExecuteSQL); err != nil { 368 error_helpers.ShowError(ctx, err) 369 } 370 c.hidePrompt = false 371 372 // cancel the context 373 c.cancelActiveQueryIfAny() 374 } else { 375 statushooks.Show(ctx) 376 defer statushooks.Done(ctx) 377 statushooks.SetStatus(ctx, "Executing query…") 378 // otherwise execute query 379 c.executeQuery(ctx, queryCtx, resolvedQuery) 380 } 381 382 // restart the prompt 383 c.restartInteractiveSession() 384 } 385 386 func (c *InteractiveClient) executeQuery(ctx context.Context, queryCtx context.Context, resolvedQuery *modconfig.ResolvedQuery) { 387 // if there is a custom search path, wait until the first connection of each plugin has loaded 388 if customSearchPath := c.client().GetCustomSearchPath(); customSearchPath != nil { 389 if err := connection_sync.WaitForSearchPathSchemas(ctx, c.client(), customSearchPath); err != nil { 390 error_helpers.ShowError(ctx, err) 391 return 392 } 393 } 394 395 t := time.Now() 396 result, err := c.client().Execute(queryCtx, resolvedQuery.ExecuteSQL, resolvedQuery.Args...) 397 if err != nil { 398 error_helpers.ShowError(ctx, error_helpers.HandleCancelError(err)) 399 // if timing flag is enabled, show the time taken for the query to fail 400 if cmdconfig.Viper().GetString(constants.ArgTiming) != constants.ArgOff { 401 display.DisplayErrorTiming(t) 402 } 403 } else { 404 c.promptResult.Streamer.StreamResult(result) 405 } 406 } 407 408 func (c *InteractiveClient) getQuery(ctx context.Context, line string) *modconfig.ResolvedQuery { 409 // if it's an empty line, then we don't need to do anything 410 if line == "" { 411 return nil 412 } 413 414 // store the history (the raw line which was entered) 415 historyEntry := line 416 defer func() { 417 if len(historyEntry) > 0 { 418 // we want to store even if we fail to resolve a query 419 c.interactiveQueryHistory.Push(historyEntry) 420 } 421 422 }() 423 424 // wait for initialisation to complete so we can access the workspace 425 if !c.isInitialised() { 426 // create a context used purely to detect cancellation during initialisation 427 // this will also set c.cancelActiveQuery 428 queryCtx := c.createQueryContext(ctx) 429 defer func() { 430 // cancel this context 431 c.cancelActiveQueryIfAny() 432 }() 433 434 // show the spinner here while we wait for initialization to complete 435 statushooks.Show(ctx) 436 // wait for client initialisation to complete 437 err := c.waitForInitData(queryCtx) 438 statushooks.Done(ctx) 439 if err != nil { 440 // clear history entry 441 historyEntry = "" 442 // clear the interactive buffer 443 c.interactiveBuffer = nil 444 // error will have been handled elsewhere 445 return nil 446 } 447 } 448 449 // push the current line into the buffer 450 c.interactiveBuffer = append(c.interactiveBuffer, line) 451 452 // expand the buffer out into 'query' 453 queryString := strings.Join(c.interactiveBuffer, "\n") 454 455 // check if the contents in the buffer evaluates to a metaquery 456 if metaquery.IsMetaQuery(line) { 457 // this is a metaquery 458 // clear the interactive buffer 459 c.interactiveBuffer = nil 460 return &modconfig.ResolvedQuery{ 461 ExecuteSQL: line, 462 IsMetaQuery: true, 463 } 464 } 465 466 // in case of a named query call with params, parse the where clause 467 resolvedQuery, queryProvider, err := c.workspace().ResolveQueryAndArgsFromSQLString(queryString) 468 if err != nil { 469 // if we fail to resolve: 470 // - show error but do not return it so we stay in the prompt 471 // - do not clear history item - we want to store bad entry in history 472 // - clear interactive buffer 473 c.interactiveBuffer = nil 474 error_helpers.ShowError(ctx, err) 475 return nil 476 } 477 isNamedQuery := queryProvider != nil 478 479 // should we execute? 480 // we will NOT execute if we are in multiline mode, there is no semi-colon 481 // and it is NOT a metaquery or a named query 482 if !c.shouldExecute(queryString, isNamedQuery) { 483 // is we are not executing, do not store history 484 historyEntry = "" 485 // do not clear interactive buffer 486 return nil 487 } 488 489 // so we need to execute 490 // clear the interactive buffer 491 c.interactiveBuffer = nil 492 493 // what are we executing? 494 495 // if the line is ONLY a semicolon, do nothing and restart interactive session 496 if strings.TrimSpace(resolvedQuery.ExecuteSQL) == ";" { 497 // do not store in history 498 historyEntry = "" 499 c.restartInteractiveSession() 500 return nil 501 } 502 // if this is a multiline query, update history entry 503 if !isNamedQuery && len(strings.Split(resolvedQuery.ExecuteSQL, "\n")) > 1 { 504 historyEntry = resolvedQuery.ExecuteSQL 505 } 506 507 return resolvedQuery 508 } 509 510 func (c *InteractiveClient) executeMetaquery(ctx context.Context, query string) error { 511 // the client must be initialised to get here 512 if !c.isInitialised() { 513 panic("client is not initalised") 514 } 515 // validate the metaquery arguments 516 validateResult := metaquery.Validate(query) 517 if validateResult.Message != "" { 518 fmt.Println(validateResult.Message) 519 } 520 if err := validateResult.Err; err != nil { 521 return err 522 } 523 if !validateResult.ShouldRun { 524 return nil 525 } 526 client := c.client() 527 528 // validation passed, now we will run 529 return metaquery.Handle(ctx, &metaquery.HandlerInput{ 530 Query: query, 531 Client: client, 532 Schema: c.schemaMetadata, 533 SearchPath: client.GetRequiredSessionSearchPath(), 534 Prompt: c.interactivePrompt, 535 ClosePrompt: func() { c.afterClose = AfterPromptCloseExit }, 536 GetConnectionStateMap: c.getConnectionState, 537 }) 538 } 539 540 // helper function to acquire db connection and retrieve connection state 541 func (c *InteractiveClient) getConnectionState(ctx context.Context) (steampipeconfig.ConnectionStateMap, error) { 542 statushooks.Show(ctx) 543 defer statushooks.Done(ctx) 544 545 statushooks.SetStatus(ctx, "Loading connection state…") 546 547 conn, err := c.client().AcquireManagementConnection(ctx) 548 if err != nil { 549 return nil, err 550 } 551 defer conn.Release() 552 return steampipeconfig.LoadConnectionState(ctx, conn.Conn(), steampipeconfig.WithWaitUntilLoading()) 553 } 554 555 func (c *InteractiveClient) restartInteractiveSession() { 556 // restart the prompt 557 c.ClosePrompt(c.afterClose) 558 } 559 560 func (c *InteractiveClient) shouldExecute(line string, namedQuery bool) bool { 561 if namedQuery { 562 // execute named queries with no ';' even in multiline mode 563 return true 564 } 565 if !cmdconfig.Viper().GetBool(constants.ArgMultiLine) { 566 // NOT multiline mode 567 return true 568 } 569 if metaquery.IsMetaQuery(line) { 570 // execute metaqueries with no ';' even in multiline mode 571 return true 572 } 573 if strings.HasSuffix(line, ";") { 574 // statement has terminating ';' 575 return true 576 } 577 578 return false 579 } 580 581 func (c *InteractiveClient) queryCompleter(d prompt.Document) []prompt.Suggest { 582 if !cmdconfig.Viper().GetBool(constants.ArgAutoComplete) { 583 return nil 584 } 585 if !c.isInitialised() { 586 return nil 587 } 588 589 text := strings.TrimLeft(strings.ToLower(d.CurrentLine()), " ") 590 if len(text) == 0 && !c.autocompleteOnEmpty { 591 // if nothing has been typed yet, no point 592 // giving suggestions 593 return nil 594 } 595 596 var s []prompt.Suggest 597 598 switch { 599 case isFirstWord(text): 600 suggestions := c.getFirstWordSuggestions(text) 601 s = append(s, suggestions...) 602 case metaquery.IsMetaQuery(text): 603 suggestions := metaquery.Complete(&metaquery.CompleterInput{ 604 Query: text, 605 TableSuggestions: c.getTableAndConnectionSuggestions(lastWord(text)), 606 }) 607 s = append(s, suggestions...) 608 default: 609 if queryInfo := getQueryInfo(text); queryInfo.EditingTable { 610 tableSuggestions := c.getTableAndConnectionSuggestions(lastWord(text)) 611 s = append(s, tableSuggestions...) 612 } 613 } 614 615 return prompt.FilterHasPrefix(s, d.GetWordBeforeCursor(), true) 616 } 617 618 func (c *InteractiveClient) getFirstWordSuggestions(word string) []prompt.Suggest { 619 var querySuggestions []prompt.Suggest 620 // if this a qualified query try to extract connection 621 parts := strings.Split(word, ".") 622 if len(parts) > 1 { 623 // if first word is a mod name we know about, return appropriate suggestions 624 modName := strings.TrimSpace(parts[0]) 625 if modQueries, isMod := c.suggestions.queriesByMod[modName]; isMod { 626 querySuggestions = modQueries 627 } else { 628 // otherwise return mods names and unqualified queries 629 //nolint:golint,gocritic // we want this to go into a different slice 630 querySuggestions = append(c.suggestions.mods, c.suggestions.unqualifiedQueries...) 631 } 632 } 633 634 var s []prompt.Suggest 635 // add all we know that can be the first words 636 // named queries 637 s = append(s, querySuggestions...) 638 // "select", "with" 639 s = append(s, prompt.Suggest{Text: "select", Output: "select"}, prompt.Suggest{Text: "with", Output: "with"}) 640 // metaqueries 641 s = append(s, metaquery.PromptSuggestions()...) 642 return s 643 } 644 645 func (c *InteractiveClient) getTableAndConnectionSuggestions(word string) []prompt.Suggest { 646 // try to extract connection 647 parts := strings.SplitN(word, ".", 2) 648 if len(parts) == 1 { 649 // no connection, just return schemas and unqualified tables 650 return append(c.suggestions.schemas, c.suggestions.unqualifiedTables...) 651 } 652 653 connection := strings.TrimSpace(parts[0]) 654 t := c.suggestions.tablesBySchema[connection] 655 return t 656 } 657 658 func (c *InteractiveClient) newSuggestion(itemType string, description string, name string) prompt.Suggest { 659 if description != "" { 660 itemType += fmt.Sprintf(": %s", description) 661 } 662 return prompt.Suggest{Text: name, Output: name, Description: itemType} 663 } 664 665 func (c *InteractiveClient) startCancelHandler() chan bool { 666 sigIntChannel := make(chan os.Signal, 1) 667 quitChannel := make(chan bool, 1) 668 signal.Notify(sigIntChannel, os.Interrupt) 669 go func() { 670 for { 671 select { 672 case <-sigIntChannel: 673 log.Println("[INFO] interactive client cancel handler got SIGINT") 674 // if initialisation is not complete, just close the prompt 675 // this will cancel the context used for initialisation so cancel any initialisation queries 676 if !c.isInitialised() { 677 c.ClosePrompt(AfterPromptCloseExit) 678 return 679 } else { 680 // otherwise call cancelActiveQueryIfAny which the for the active query, if there is one 681 c.cancelActiveQueryIfAny() 682 // keep waiting for further cancellations 683 } 684 case <-quitChannel: 685 log.Println("[INFO] cancel handler exiting") 686 c.cancelActiveQueryIfAny() 687 // we're done 688 return 689 } 690 } 691 }() 692 return quitChannel 693 } 694 695 func (c *InteractiveClient) listenToPgNotifications(ctx context.Context) { 696 c.initData.Client.RegisterNotificationListener(func(notification *pgconn.Notification) { 697 c.handlePostgresNotification(ctx, notification) 698 }) 699 } 700 701 func (c *InteractiveClient) handlePostgresNotification(ctx context.Context, notification *pgconn.Notification) { 702 if notification == nil { 703 return 704 } 705 n := &steampipeconfig.PostgresNotification{} 706 err := json.Unmarshal([]byte(notification.Payload), n) 707 if err != nil { 708 log.Printf("[WARN] Error unmarshalling notification: %s", err) 709 return 710 } 711 switch n.Type { 712 case steampipeconfig.PgNotificationSchemaUpdate: 713 c.handleConnectionUpdateNotification(ctx) 714 case steampipeconfig.PgNotificationConnectionError: 715 // unmarshal the notification again, into the correct type 716 errorNotification := &steampipeconfig.ErrorsAndWarningsNotification{} 717 if err := json.Unmarshal([]byte(notification.Payload), errorNotification); err != nil { 718 log.Printf("[WARN] Error unmarshalling notification: %s", err) 719 return 720 } 721 c.handleErrorsAndWarningsNotification(ctx, errorNotification) 722 } 723 } 724 725 func (c *InteractiveClient) handleErrorsAndWarningsNotification(ctx context.Context, notification *steampipeconfig.ErrorsAndWarningsNotification) { 726 log.Printf("[TRACE] handleErrorsAndWarningsNotification") 727 output := viper.Get(constants.ArgOutput) 728 if output == constants.OutputFormatJSON || output == constants.OutputFormatCSV { 729 return 730 } 731 732 c.showMessages(ctx, func() { 733 for _, m := range append(notification.Errors, notification.Warnings...) { 734 error_helpers.ShowWarning(m) 735 } 736 }) 737 738 } 739 func (c *InteractiveClient) handleConnectionUpdateNotification(ctx context.Context) { 740 // ignore schema update notifications until initialisation is complete 741 // (we may receive schema update messages from the initial refresh connections, but we do not need to reload 742 // the schema as we will have already loaded the correct schema) 743 if !c.initialisationComplete { 744 log.Printf("[INFO] received schema update notification but ignoring it as we are initializing") 745 return 746 } 747 748 // at present, we do not actually use the payload, we just do a brute force reload 749 // as an optimization we could look at the updates and only reload the required schemas 750 751 log.Printf("[INFO] handleConnectionUpdateNotification") 752 753 // first load user search path 754 if err := c.client().LoadUserSearchPath(ctx); err != nil { 755 log.Printf("[WARN] Error in handleConnectionUpdateNotification when loading foreign user search path: %s", err.Error()) 756 return 757 } 758 759 // reload schema 760 if err := c.loadSchema(); err != nil { 761 log.Printf("[WARN] Error unmarshalling notification: %s", err) 762 return 763 } 764 765 // reinitialise autocomplete suggestions 766 767 if err := c.initialiseSuggestions(ctx); err != nil { 768 log.Printf("[WARN] failed to initialise suggestions: %s", err) 769 } 770 771 // refresh the db session inside an execution lock 772 // we do this to avoid the postgres `cached plan must not change result type`` error 773 c.executionLock.Lock() 774 defer c.executionLock.Unlock() 775 776 // refresh all connections in the pool - since the search path may have changed 777 c.client().ResetPools(ctx) 778 }