Skip to content
 
 

Latest commit

 

History

114 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dbsh.nvim

Query PostgreSQL from Neovim without leaving your editor, without blocking it, and without putting a password in your config.

dbsh.nvim

require("dbsh").setup({
	connections = {
		local_db = { host = "localhost", port = 5432, database = "postgres", username = "dev" },
	},
	default = "local_db",
})

Lineage

This is a fork of harrisoncramer/psql, itself forked from mzarnitsa/psql. Those projects contributed the core idea this one still rests on: write SQL in a normal buffer, read the result in another one, no modal UI in between.

Everything under that idea has been rewritten. Execution moved off the main thread, authentication moved to ~/.pgpass, the schema became browsable, and the plugin gained a test suite. If you are coming from either upstream, read Migrating — the configuration format changed.

What you get

upstream dbsh.nvim
Execution vim.fn.systemlist, editor frozen until the query returns vim.system, asynchronous and cancellable
Authentication password or hash stored in your Neovim config ~/.pgpass, resolved by psql itself
Switching database one Lua file per connection, hand-written Telescope picker over declared connections
Schema browsing none connections, databases, schemas, tables
Ad-hoc queries scratch buffer, lost on exit a real .sql file per connection
Getting data out one cell at a time CSV to file, or CSV from a visual selection
Result buffer soft-wrapped, editable horizontal scroll, read-only
Lua namespace lua/psql.lua, lua/util/, lua/hash/ everything under lua/dbsh/
Tests none 95 cases on mini.test, make test

It never blocks

Queries run through vim.system. The editor stays responsive while one is in flight, and :DbCancel kills it. A generation counter invalidates results that belong to a connection you have already left, so a slow answer can never overwrite a fresh one.

It never asks for a password

There is no password field to fill in, no hash, and no PGPASSWORD in your process list. psql is always invoked with -w and resolves credentials from ~/.pgpass, which is the mechanism PostgreSQL already ships for this.

It knows your schema

Table picker

Four pickers, and a drill-down from schemas into their tables. Selecting a table previews it immediately.


Requirements

  • Neovim 0.10+ — the plugin uses vim.system, vim.fn.getregion and vim.fs.joinpath
  • psql on your PATH
  • telescope.nvimoptional; every picker degrades to a clear message without it, and queries work fine
  • postgres-language-serveroptional; only needed if you turn on lsp.enabled, see Language server

Installation

lazy.nvim
{
	"edjubert/dbsh.nvim",
	dependencies = { "nvim-telescope/telescope.nvim" },
	config = function()
		require("dbsh").setup({
			connections = {
				local_db = { host = "localhost", port = 5432, database = "postgres", username = "dev" },
				staging = { host = "db.example.com", port = 5432, database = "app", username = "readonly" },
			},
			default = "local_db",
		})
	end,
}
packer.nvim
use({
	"edjubert/dbsh.nvim",
	requires = { "nvim-telescope/telescope.nvim" },
	config = function()
		require("dbsh").setup({ --[[ ... ]] })
	end,
})

To get the pickers under :Telescope, load the extension:

require("telescope").load_extension("dbsh")
-- :Telescope dbsh tables

Quick start

  1. Declare a connection in setup() — four fields, no password.
  2. Add a line for it to ~/.pgpass, then chmod 600 ~/.pgpass.
  3. Open a .sql file, put the cursor in a statement, and run :lua require("dbsh").query_paragraph().

If a password prompt appears, ~/.pgpass is being ignored — see Troubleshooting.

Configuration

require("dbsh").setup({
	connections = {
		local_db = { type = "postgres", host = "localhost", port = 5432, database = "postgres", username = "dev" },
	},
	default = "local_db",
	connect_timeout = 5,
	query_timeout = 30000,
	preview_limit = 10,
	csv_delimiter = ",",
	export_dir = vim.fs.joinpath(vim.fn.stdpath("data"), "dbsh", "exports"),
	results_split = "horizontal",
	variable_patterns = {}, -- e.g. { ":(raw_data)" }, see SQL variables below
	lsp = { enabled = false }, -- steer postgres-language-server, see below
})
Option Default Meaning
connections {} Named connections. type names the backend driving it and defaults to "postgres"; a postgres connection then needs host, port, database, username.
default nil Connection selected at startup. Falls back to any declared one.
connect_timeout 5 PGCONNECT_TIMEOUT, in seconds.
query_timeout 30000 Kills a runaway query, in milliseconds.
preview_limit 10 LIMIT used when previewing a table from the picker.
csv_delimiter "," Column separator, for both CSV export and CSV yank.
export_dir <stdpath("data")>/dbsh/exports Where :DbExportCSV suggests writing.
results_split "horizontal" "horizontal", "vertical" or "float": which window opens __DBSH__ in. Only applies the first time the window is created; combine with vim.opt.splitright = true for a right-hand split. "float" is styled after your telescope config, when installed.
variable_patterns {} Lua patterns (one capture each) naming SQL variables to prompt for. See SQL variables.
lsp { enabled = false } Point an already-running postgres-language-server at the connection you pick. Off by default: it talks to a client dbsh does not own.

There is deliberately no password field.

Authentication

psql resolves passwords from ~/.pgpass. The plugin passes -w, so it never prompts and never leaks a password into the process list.

# ~/.pgpass — hostname:port:database:username:password
db.example.com:5432:*:readonly:secret
localhost:5432:*:dev:secret

The file must be chmod 600:

chmod 600 ~/.pgpass

PostgreSQL silently ignores a .pgpass with looser permissions. The symptom is an unexpected password prompt, and it is the single most common setup mistake.

Language server

postgres-language-server validates SQL against a real database: it will tell you a column does not exist before you run the query. It only knows one database at a time, declared in a configuration file — which does not survive someone who changes database ten times a day.

Turn this on and the database you pick with :DbDatabases becomes the database it diagnoses against, with no restart and no file editing:

require("dbsh").setup({
	connections = { ... },
	lsp = { enabled = true },
})

dbsh does not start, stop or install the server: it talks to the client you already run, through lspconfig or mason. It applies to every .sql file in the session, not just the scratchpad. Picking a schema with :DbSchemas pushes the same connection again, which costs nothing.

After every successful query, dbsh clears the server's schema cache and warms it back up, so a CREATE TABLE is reflected in completion right away. It refreshes unconditionally rather than guessing which statements were DDL — dbsh never parses your SQL. The warm-up takes roughly half a second in the background, which is why it happens then rather than under your fingers on the next completion.

The password

dbsh pushes the host, the port, the user and the database. Never the password. There is deliberately no password field in a dbsh connection, and that stays true here: a password pushed through the LSP protocol would land in client.settings, and in plain text in the Neovim LSP log for anyone running vim.lsp.log at debug level.

The server only overrides the fields it receives, so the password you declare elsewhere survives. Three ways to give it one:

  1. A password in the project's postgres-language-server.jsonc — the nominal path, and what survives dbsh pushing everything else.
  2. PGPASSWORD in Neovim's environment.
  3. No password at all: trust, a Unix socket, or peer authentication.

If none applies, the server falls back to its own default, the connection fails, and static diagnostics keep working — parse errors and lints are unaffected. Only completion and type-checking stop, and they recover on their own once the connection succeeds.

Three things that will silently defeat it

  • A connectionString in your postgres-language-server.jsonc wins over everything. The server reads the URI first and ignores the individual fields, and dbsh cannot erase a field it does not send. Use the separate host/port/username/database fields in that file.
  • PGHOST, PGPORT, PGUSER, PGDATABASE or DATABASE_URL in your environment win too, because the server merges the environment last. This is a real trap if you live in psql and export PGDATABASE. dbsh warns once per session when it sees one.
  • Editing postgres-language-server.jsonc mid-session makes the server reload the file and lose what dbsh pushed. Switching connection fixes it.

Commands

Command Description
:DbConnections pick a connection
:DbTemp open the scratchpad of the current connection
:DbToggleResults toggle the result window closed or open, keeping its content
:DbExportCSV export a query result to a CSV file — accepts a range
:DbCancel cancel the running query
:DbInfo show the current connection and database

The catalog commands are declared by the backend of the current connection, not by the plugin, and are redeclared whenever you switch connection. A postgres connection gives you:

Command Description
:DbDatabases pick a database on the current server
:DbSchemas pick a schema, then drill into its tables
:DbTables pick any table, as a flat schema.table list

A backend with a different hierarchy declares different commands, and the ones that do not apply simply do not exist — completion only offers what makes sense for the connection you are on.

Lua API

The plugin defines no keymaps. These are the functions worth binding:

Function Description
query(sql) run an arbitrary string
query_paragraph() run the block around the cursor, delimited by blank lines
query_current_line() run the line under the cursor
query_selection() run the visual selection, in any visual mode
yank_cell() copy the result cell under the cursor
yank_csv() copy the selected result cells as CSV
export_csv(opts) the function behind :DbExportCSV
last_query() the last query handed to query(), or nil

Keymaps

local dbsh = require("dbsh")
local opts = { noremap = true, silent = true, nowait = true }

vim.keymap.set("n", "<localleader>r", dbsh.query_paragraph, opts)
vim.keymap.set("v", "<localleader>r", dbsh.query_selection, opts)
vim.keymap.set("n", "<localleader>e", dbsh.query_current_line, opts)
vim.keymap.set("v", "<localleader>e", dbsh.query_selection, opts)
vim.keymap.set("n", "<localleader>y", dbsh.yank_cell, opts)
vim.keymap.set("v", "<localleader>y", dbsh.yank_csv, opts)

Bind every key in both normal and visual mode, even where one of the two looks redundant. An unmapped <localleader> prefix falls through in visual mode, and the next key is then read as a plain Vim command: with <localleader>r left unmapped, pressing it over a selection runs r, which silently replaces every selected character. Mapping it closes that trap.

normal mode visual mode
<localleader>r run the paragraph run the selection
<localleader>e run the current line run the selection
<localleader>y yank the cell under the cursor yank the selection as CSV

Running queries

Three ways to send SQL, none of which need a precise selection:

  • query_paragraph() — the block of lines around the cursor, bounded by blank lines. This is the one you will reach for. Separate your statements with a blank line and you never have to select anything.
  • query_current_line() — just the line under the cursor.
  • query_selection() — whatever is selected, in any visual mode.

While a query runs, the result buffer shows a # Running... placeholder, and :DbCancel kills the process.

Results buffer

Result buffer

Results land in a reused __DBSH__ buffer, which keeps previous queries in view as a working history.

Wrapping is off, so wide tables scroll horizontally (zl / zh, zL / zH for bigger jumps) instead of folding into unreadable blocks. The buffer is also read-only: it is rendered by the plugin, and hand edits would only desync it from what psql returned.

On failure, stderr is rendered in place of the result, so a syntax error reads exactly where you expect the table.

Pickers

DbSchemas, a schema highlighted Tables of the selected schema
  • :DbConnections — switch between the connections you declared.
  • :DbDatabases — every database on the current server. Selecting one keeps the same host, port and user, and swaps only the database.
  • :DbSchemas — pick a schema, then land in its tables. <BS> goes back to the schema list; it is bound in normal mode only, so backspace still edits the prompt.
  • :DbTables — every table, as a flat schema.table list, annotated with its kind: table, view, matview, partitioned.

Selecting a table runs SELECT * FROM "schema"."table" LIMIT 10;, with the limit taken from preview_limit. Identifiers are quoted, so mixed-case names and reserved words survive.

Catalog navigation is generic: the backend declares an ordered list of levels, and the plugin renders each of them with the same picker. <BS> walks back up that list. Introspection runs on its own execution slot, which means opening a picker never cancels a query you are waiting on.

Backends

The plugin drives a database shell as a subprocess; which shell it drives is a property of the connection:

connections = {
	local_db = { type = "postgres", host = "localhost", port = 5432, database = "postgres", username = "dev" },
}

type defaults to "postgres", which is the only backend implemented today. A backend is a small table under lua/dbsh/backends/: it says how to build the CLI invocation, what preamble to write, how to parse raw output, how to declare a query variable, how to export CSV, and which navigation levels its catalog has. Everything else — the result buffer, the CSV yank, the scratchpad, the variable prompts, the export file handling — is shared and knows nothing about any particular database.

Scratchpad

Scratchpad

:DbTemp opens <stdpath("data")>/dbsh/<connection>.sql — a real file on disk, not a throwaway buffer. Your SQL LSP, your formatter and persistent undo all work normally, and the file survives restarts.

Each connection gets its own, so your working queries follow the database you are working on.

SQL variables

Shared SQL files often target a table that changes from one run to the next:

SELECT * FROM :raw_data WHERE created_at > now() - interval '7 days';

Declare which names are variables, and dbsh.nvim asks for their value before running the query:

variable_patterns = { ":(raw_data)" },

Each entry is a Lua pattern with one capture, which gives the variable name. This mirrors the User Parameters setting of JetBrains DataGrip, where the same declaration reads :(raw_data). The default is an empty list, so no existing SQL file changes behaviour until you opt in.

Variable prompt

The prompt doubles as the input field: the list holds the values you already used for that variable, most recent first. <CR> takes the highlighted entry, or the text you typed when nothing matches. <C-e> always takes the typed text, which is how you enter a value that happens to be a substring of an existing one. Dismissing the prompt cancels the whole run — a half-parameterised query never reaches the server.

Without telescope.nvim, the prompt degrades to vim.ui.input, prefilled with the most recent value.

Values are remembered per connection in <stdpath("data")>/dbsh/vars/<connection>.json, deduplicated and capped at 50 per variable. Table and schema names only make sense on the database they came from, which is the same reasoning the scratchpad follows. Edit or delete that file by hand if you need to clean it up.

How substitution works

The plugin does not rewrite your SQL. It emits \set directives ahead of the query and lets psql interpolate :name itself. Two consequences worth knowing:

  • Nothing is ever substituted inside a quoted literal, because psql does not interpolate there. That is the guarantee DataGrip has to offer as a checkbox.
  • Only :name-shaped variables can work. A bare token with no colon is not something psql can interpolate.

Choosing a pattern

A wide pattern catches everything at once:

variable_patterns = { ":([%w_]+)" },

Be aware it also matches things you did not mean: a::text yields a variable named text, and '12:30' yields 30. Naming each variable explicitly, as in ":(raw_data)", avoids this entirely and is the recommended default.

CSV export

CSV export prompt

:DbExportCSV writes a query result to a .csv file. What it exports depends on where you run it:

  • from the __DBSH__ buffer — the query currently displayed, re-run;
  • over a visual selection — the selected lines;
  • anywhere else — the SQL paragraph under the cursor.

Queries holding SQL variables are resolved before the export, so :DbExportCSV asks for their values first and the destination path second.

The suggested path is <export_dir>/<YYYYMMDD>_<connection>.csv, or ..._scratchpad.csv when no connection is selected. It gains a _1, _2 suffix when the file exists, and the prompt lets you edit it. An existing file is never overwritten: the suffix is applied again to whatever path you confirm.

Under the hood it runs COPY (...) TO STDOUT WITH (FORMAT CSV, HEADER). That needs no superuser right, keeps multi-line queries valid, and the file is written by Neovim on the machine you are sitting at.

CSV yank

CSV yank

yank_csv() turns a visual selection of the rendered table into CSV:

  • V takes every column of the selected rows;
  • <C-v> takes only the columns the block covers, which also handles the single-cell case.

Character-wise v is rejected with a message rather than guessed at — the meaning of a character selection across a drawn table is ambiguous.

Frame and separator lines are ignored, so a selection that overshoots the table still yields clean CSV. Values are escaped per RFC 4180: a field holding the delimiter, a quote or a newline gets quoted, and inner quotes are doubled.

The text goes to the default register and to the clipboard registers your 'clipboard' option asks for — + under unnamedplus, * under unnamed. So vim.opt.clipboard = "unnamedplus" is enough to paste it into a spreadsheet.

For a single cell without selecting anything, yank_cell() grabs the one under the cursor.

(NULL) versus empty. A CSV yank copies what is on screen, so a null cell reads (NULL). A CSV export goes through COPY, which knows the real SQL null and writes an empty field. Same data, two honest representations.


Troubleshooting

A password prompt appears. ~/.pgpass is not chmod 600, or has no line matching this host, port, database and user. PostgreSQL ignores an over-permissive file without saying so.

:DbTables says telescope is required. Telescope is optional but needed for the pickers. Queries still work without it.

yank_csv reports success but nothing pastes. Your 'clipboard' sends puts through +. This is handled since the CSV yank honours 'clipboard' — make sure you are on a current version.

Pressing <localleader>r over a selection mangles the buffer. The prefix is unmapped in visual mode, so Vim's r takes over. See Keymaps.

A query hangs. :DbCancel kills it. query_timeout caps it automatically. Note that killing psql does not repair a degraded SSH tunnel underneath.

Migrating from an upstream version

  1. Replace harrisoncramer/psql or mzarnitsa/psql with edjubert/dbsh.nvim.
  2. Delete your ~/.config/nvim/lua/psql/<name>.lua files. Move their host, port, database and username into the connections table passed to setup(), and drop password and hash_algorithm.
  3. Create ~/.pgpass, chmod 600 it, and add a line per connection.
  4. :PSQL <name> is gone — use :DbConnections.

The parts you knew are unchanged: the result buffer is still __DBSH__, and paragraph, line and selection queries behave the same.

Migrating from psql.nvim

The plugin was renamed to make room for other database shells. The break is clean: there is no deprecated :PSQL* alias and no psql.* Lua namespace.

  • Replace require("psql") with require("dbsh") in your config.
  • Rename your commands: :PSQLTables becomes :DbTables, and so on.
  • Move your stored data, which is not migrated automatically:
mv ~/.local/share/nvim/psql ~/.local/share/nvim/dbsh

Development

make test

Tests run on mini.test, cloned into deps/ automatically. Set MINI_TEST_DIR if you keep it elsewhere.

The modules are small and single-purpose, which is what makes them testable:

Module Responsibility
config.lua declared connections, current selection, generation counter
exec.lua asynchronous psql invocation, execution slots, cancellation
results.lua the __DBSH__ buffer
introspect.lua catalog queries and their parsing
scratch.lua per-connection scratchpad file
csv.lua table parsing and CSV serialization, pure functions
export.lua destination paths and the COPY statement
telescope/pickers.lua the four pickers
init.lua public API and user commands

Credits

About

Neovim PostgreSQL client, with multi-DB support

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages