A terminal database client: a DataGrip-style workspace for your databases, in the terminal.
datarig is written in Rust with Ratatui. It keeps several connections open side by side, each with its own tabs, a schema explorer, a SQL editor with vim keys and a results grid that pages through large results explicitly instead of loading them whole. It is careful by default: dangerous statements ask first, and read-only profiles are enforced by the server.
Early, pre-1.0. Releases have binaries for macOS, Linux and Windows and a Homebrew formula (Install); the latest is 0.24.1. PostgreSQL is implemented, and MySQL in part (see MySQL for what it does and does not do yet). The configuration format, key bindings and behavior may still change between releases (see Versioning).
Platforms: developed and tested on macOS; CI runs the full test suite on Linux. On Windows CI builds datarig, lints it and runs the tests that need no database, but running datarig there is untested: the Windows notes below are how it is meant to work, not something anyone has tried yet.
Everything below works today with PostgreSQL; MySQL says which parts work with MySQL.
Workspace
- Several connections at once, each with its own metadata session, schema tree and completion
catalog. A tab belongs to one connection; tabs of different connections sit side by side.
A tab's number shows its connection: in the profile's color while connected, muted while not,
and a spinner in its place while a statement runs.
◆after the name marks your open transaction,!an aborted transaction or a lost connection. - A tab list (
Space t t,:tabs,:lsor:buffers, also in the action menu), as nvim's:buffersor Telescope's buffer picker: the open tabs, the most recently used first with the previous one selected (Entergoes back to it), then the tabs closed in this session. Each row shows the tab's number, name, kind and connection, database and schema, with the tab bar's marks. Type to filter by any of them;Entergoes to a tab or brings a closed one back,Ctrl+Dcloses the selected tab (asking first asCtrl+Wdoes). - Tabs can be put in another order: drag one with the mouse as in a browser (it follows the
pointer over the other tabs, which slide out of its way; past an end of a bar with hidden
tabs the bar scrolls;
Esc, or letting go far from the bar, puts every tab back), by keys the same way (Space t g, thenh/lor←/→,Enterkeeps,Escputs it back),Space t </Space t >, the tab's menu, or:tabmoveas in Vim (:tabmove 0first,:tabmovelast,:tabmove +1). The tab bar's numbers (andSpace 1..Space 9) follow the new order; a console keeps its own name (console 3). The order is kept across restarts. - The explorer lists every connection profile (in its Connections section, which opens and
closes like the Tunnels, Environments and Saved queries sections below it and is
kept closed across restarts; each section's head shows its icon with icons on, its name and
how many it has), the server's databases, their schemas, tables and
views (materialized views with an icon of their own). A profile through an SSH tunnel carries
the tunnel mark (
SSHwith icons off), and one with read-only rules a lock in blue (ROwith icons off): that comes from the rules alone, never from an environment's name. Each table and materialized view shows the server's estimates of its rows and its size on disk on its own line, dim and on the right (orders ~50k rows · 19 MB), read with the schema's list in the same round trip from the statistics thatVACUUMandANALYZEkeep, without opening it and without a lock on any table (rlists them again); a table that has grown to more than twice the rows its lastANALYZEsaw shows the live rows the server has counted since, its size grown with them. The largest tables of each schema stand out: of those with a known size, the largest tenth (one at least) has its estimates in amber from 1 MiB and in red from 1 GiB, from the estimates already read.[explorer] size_heat = "5"(or:set explorer.size_heat=5) uses five levels instead: yellow from 1 MiB, amber from 100 MiB, orange from 1 GiB, red from 10 GiB;"off"keeps them all dim ("3"is the default, and"on"still reads as"3"). The name always wins: on a short line the size goes first, then the rows; a table with no statistics yet shows nothing there, and the status bar says "rows and size unknown (no statistics yet)". An open table shows its structure, read from the catalog in one round trip the first time it opens (rreads it again, and its estimates with it; the table itself is never read): Columns (key marks, type,not null, default), Primary Key, Foreign Keys (Entergoes to the referenced table), Indexes (columns or expressions with their order, operator class and collation, as in(created DESC),UNIQUE, method, partial predicate, whether a key or constraint owns it), Unique and Check Constraints and Triggers (timing, events with the columns ofUPDATE OF, row or statement, function, disabled; a trigger with aWHENcondition opens to it). Each key, index and check (one that reads columns) opens toColumns (n), which lists the columns it covers in its order, each as the Columns group shows it: an index key with its order, operator class and collation, an expression key as its text, theINCLUDEcolumns last and marked, a foreign key's columns with the column each references (user_id → users.id). Views show their columns and triggers, materialized views their columns and indexes. The groups start closed, one line each (Columns (27),Primary Key,Foreign Keys (5)); what you open in a table stays open when it closes and opens again, for the session. A group with nothing in it is dim, without a count. The explorer is narrow, so the status bar shows the whole line under the cursor (a line too long even for the status bar is cut in its middle, so its end stays: what to do, what a trigger calls). A lookup never waits on another session: a table another session locks (anALTER TABLE, aVACUUM FULL, a migration waiting for its lock) says "structure unavailable: the table is locked by another session (try again)" without asking for a lock that would queue behind it: at once when the lock is already there, after at most 2 s when it is taken while the structure is being read. Nerd Font icons are optional (asked once,:set icons=on|off). /filters the explorer by a part of a name, ignoring case: profiles (by name, host or database), folders (a matching folder shows everything in it), tunnel presets, environments and saved queries, and under a connected profile the databases, schemas, tables and views the explorer has already read, and the structure items (columns, keys, indexes, constraints, triggers) of a table whose structure it has read (where it shows a table's columns from the completion catalog instead, they are not searched). A schema whose tables were never listed and a table whose structure was never opened are not searched, nor is the tree of a profile that is not connected (its name still matches), and nothing is asked of the server. Matches show with the way to them opened and the cursor on the first one; what you opened or closed yourself does not change, andEscgives the tree back as it was.- Show DDL (PostgreSQL; MySQL shows the server's own text, see MySQL):
Don a table, view, materialized view, index or trigger in the explorer (or its menu, or:ddl [schema.name]),Fon a trigger for its function, opens itsCREATEstatements in a read-only tab, rebuilt from the catalog in one round trip and marked as such (-- Reconstructed by datarig from the catalog (not pg_dump)): owned sequences, columns with their collations, defaults, identity and generated expressions, constraints, partitioning or inheritance, storage parameters, owner, indexes, triggers, row-level security and policies, comments and grants (not rows, sequence values, rules, extended statistics or security labels). Every name the server prints is qualified. The text can be moved in, searched and yanked like any other; edits are refused.oopens it in a new console on the same connection,r(or the run key) reads it again; a tab restored at the next start waits for that. Reading it never waits on another session: when the object, or a table its definition reads (a view's tables, a policy's subquery), is locked by another session, the tab says so and shows nothing of the DDL. - Connection profiles with colors, icons and nested folders; a quick-connect list (
Ctrl+O) with fuzzy search; a database and schema per tab (:use db.schema). - Saved queries as plain
.sqlfiles in folders, autosave of every tab, and the workspace (tabs, cursors, open folders) restored at the next start. Closed consoles go to a trash (:recover). A second instance opens read-only instead of fighting over the files. - The layout:
Space zzooms the focused pane (explorer, editor, results or inspector) to the whole workspace and back, as tmux does (ZOOMin the status bar; moving the focus to another pane ends it; the results'zis the same zoom).Space bhides or shows the explorer;Space </Space >or a drag of its right border resize it, and a drag of the results' top border resizes them. The explorer's width and whether it is hidden are kept across restarts. - English and Korean UI (
:set language=en|ko|auto). - Themes: by default the terminal's own colors; built-in truecolor themes and your own theme files (Themes).
Passwords and connections
- Password sources per profile: the OS keychain (every call off the UI thread, with a time
limit, so a locked or remote keychain cannot freeze the app), a
0600secrets file, a command (password_command, for example a password manager's CLI), an environment variable, or a prompt on every connect. Plaintext passwords found in the config file are moved to the keychain at launch. On macOS the keychain can be reached through Apple'ssecuritytool instead, so upgrades of the unsigned binary stop asking again (opt in, with a trade-off: macOS: keychain access throughsecurity). - Connection URLs (
postgres://user@host:port/db?...) can be pasted into the profile form. - TLS for PostgreSQL and MySQL (rustls): libpq's
sslmodevalues (butallow) and MySQL's--ssl-modevalues, a CA file, a client certificate; the server's certificate checked against the CA file or the system's trusted certificates, also through an SSH tunnel. Each connection says how it is encrypted (TLS). - SSH tunnels through one bastion per profile: key files (OpenSSH, PEM including AWS
.pem, PKCS#8, encrypted or not, with certificates), password, ssh-agent, or keyboard-interactive (one-time codes). Host keys are checked against~/.ssh/known_hosts(read only; hashed names, wildcards,@revoked) and datarig's ownknown_hosts; an unknown or changed key is always asked about, never accepted silently. Every session, cancel request and test connection of the profile goes through the tunnel. - Tunnel presets: named SSH tunnels in the explorer's Tunnels section (make, edit, copy, delete, test), which profiles pick in their form. Profiles that use the same preset at the same time share one SSH connection: one login, one host key question, one secret prompt; it closes when the last of them disconnects. A tunnel of a profile's own still works, and its form can save it as a preset (see SSH tunnels).
- PgBouncer and other transaction poolers: nothing outside the query session relies on
prepared statements, statement names are unique per process, and a profile can turn the
server-side statement cache off (
statement_cache = false). When the server keeps losing prepared statements, the session turns the cache off by itself and says so. - A test connection from the profile form, with the server version, the latency, how it was encrypted (and the tunnel's time).
SQL editor
- Vim keys: Normal, Insert and Visual modes, with counts. The motions
hjkl,wbe,WBE,gegE,0^$,ggG,fFtT;,,%(brackets in strings and comments do not count),{},HML; the operatorsdcy,gugUg~and><with any motion or text object (iwawiWaW,i(a(,i[,i{,i<,i"i'i`,ipap, inner and around), doubled for lines (ddccyy>>);xXsSDCY,pP,r,JgJ,~,uandCtrl+R;.repeats the last change, what was typed included;iaIAoO;Ctrl+DCtrl+UCtrl+FCtrl+Bandzzztzbscroll; Visual mode by character (v) or by line (V), wherepandPput a register in place of the selection, and by block (Ctrl+V): screen columns, so wide characters (Hangul, CJK) line up,$to each line's end,oO,ydcDC,IandA(what you type goes on every line of the block),r~uU><JandpP, as Vim does them;.repeats a block operator on a block of the same size. One command is one undo step. The status bar saysistarts typing andEscstops. Insert mode keeps the usual editing keys (arrows,Home/End,Backspace/Delete), plusCtrl+W,Ctrl+UandCtrl+R {register}, andEnterkeeps the line's indent; a paste from the terminal goes in at the cursor in every mode. Auto-pairs are off by default, as in Vim;[editor] auto_pairs = "on"makes( [ { ' "and`put their closing character after the cursor (not inside strings or comments, not before a word, never for a paste), step over it when you type it, and delete the empty pair onBackspace. Registers work as in Vim ("a-"zand"A-"Zto append,"0, the"1-"9delete ring,"-,"_,".,"/,"+/"*;":"%"#are not kept), and like Vim withclipboard=unnamedplusevery yank, delete or change without a register also goes to the system clipboard (or through OSC 52 over SSH;[editor] clipboard = "off"turns that off)."+preads the system clipboard only when you type it; over SSH use the terminal's paste. Search with/and?(a prompt on the editor's last line; the cursor shows the match as you type),nN,*#andg*g#, with counts and after an operator (d/from); matches on screen stay highlighted until:noh. Search patterns are Rust regular expressions, not Vim's: case sensitive unless the pattern starts with(?i),\bfor a word boundary, and a match never spans two lines. Marks:m{a-z},'aand`a(also after an operator,d'a),''back to where the last jump left from,'<'>for the last Visual selection and'.for the last change; marks follow their lines through edits and come back on undo.gccomments lines out with--or back in as Neovim does (gcc,gc{motion},gcin Visual mode; alsoSpace e c). On the:line the editor takes a line number (:12,:$,:'a) and:s(:%s/old/new/g,:'<,'>s/^/-- /,:&&, and&/g&in Normal mode): the pattern is a Rust regular expression as in search, the replacement Vim's (&,\1,\r,\u,~;$is plain text), flagsgiIe(c, confirm, is not supported). One:sis one undo step. Macros are not there yet (their keys are reserved). Commands keep working with a Korean (2-Set) input source: the jamo are read as the QWERTY keys they sit on, and the characterf,torrwaits for is taken as typed. The supported keys are listed in docs/keybindings.md. - Syntax highlighting, completion of schemas, tables and columns (aliases included), and the
statement under the cursor marked in the gutter. Completion inserts names as SQL: a name that
is not a plain lower-case word (
MixedCase,Order Lines) or is a reserved word goes in quoted, also when you started it with". The statement'sWITHqueries are offered as tables, with their columns when the query lists them or names them in its select list. Names that start with what you typed come first, then names that contain its letters in order (oifindsorder_items); keywords only by their start. - Completion of values in a string literal compared with a column (
status = ',<>,!=,status IN ('paid', ',NOT IN,UPDATE … SET status = '; aliases and qualified names as above): the column's allowed values, filtered as you type. They come from its type (PostgreSQL enums, also through domains; MySQLENUM), then from its table's CHECK constraints of the formscol IN ('a', 'b')(PostgreSQL writes itcol = ANY (ARRAY[…])),col = 'a'and anORof those on one column. The checks are read with the table's structure (PostgreSQL 12+, MySQL 8.0.16+), once per table until the explorer is refreshed, so their values show a moment after the first use. A value goes in escaped as the literal reads it ('', and a backslash doubled where a backslash escapes), and an unclosed literal gets its closing quote. A type's values are all offered even where a check allows fewer (they are not narrowed by it). A value in a function call (lower(status) = ') gets none. Like every popup it shows 10 items at most;INSERT … VALUESis not covered. - A formatter you run yourself (
Space e f,:format,:'<,'>format; never automatic): it lays out the statement under the cursor or the selection, as one undo step. It changes only the layout (line breaks, indent of 4 or 2 spaces, and keyword case if you ask for it with[editor] format_keyword_case = "upper"or"lower"); names, strings, comments and dollar-quoted bodies stay exactly as written. Every result is checked token by token against the original, and when anything else would change (operator characters split, strings joined across a line break,U&'...', psql meta-commands) it refuses, says on which line, and leaves the text alone. - Run the statement under the cursor (
Ctrl+E), a selection, or several statements in a row: each statement's outcome is listed in a Messages tab, and every row result gets a result tab of its own, with a Chart tab after them (the tab strip is there from the first result on, in a console and in a table tab; in the results paneH/Lmove between the tabs, as the right end of the strip says when there is room;Space r [/Space r ]from anywhere). Queries can be cancelled (Ctrl+C). When a PostgreSQL cancel cannot reach the server (a tunnel that refuses it, a network failure), datarig closes the tab's connection and says why; it cannot tell what became of the statement: it may still run to its end and commit if it was not in a transaction block, an open block is rolled back when the server notices the closed connection, and the session's temporary tables, settings and advisory locks are gone. While a run goes on, the statement it is executing has a tint of its own and a spinner in the gutter, wherever the cursor goes and whatever you edit around it. When it ends, a dim hint after each statement's last line says what it did (✓ 128 rows · 42ms · 14:03, rows affected, the error, rolled back, cancelled); the hint is not part of the text (never yanked, saved or searched) and goes when you edit the statement.[editor] run_hints = "off"turns the hints off. Ctrl+Gopens the query in your own editor ($VISUAL, else$EDITOR, elsevi; for exampleEDITOR="code -w"), run directly without a shell on a private temporary file; what you save replaces the text as one undo step, and quitting without saving (or Vim's:cq) changes nothing. A table tab's query opens as a copy that comes back in a new console.Ctrl+Z(or:suspend) suspends datarig like Vim, andfgbrings it back (not on Windows). A running query keeps running on the server meanwhile.
Results
- A grid that shows one page at a time (
n/p); the rows already fetched stay in a bounded memory window and spill to a private temporary file past it. By default nothing stays open on the server between pages: the first page ends its transaction, and the next page runs the statement again when it is safe to repeat (said each time); otherwise you see the first page and why. An environment can keep the server-side cursor open instead (paging = "hold", see Paging and locks). - A row count on demand, with a label that says whether it matches the pages you saw.
- An inspector for the selected row or cell (JSON pretty-printed; it starts on the Row tab for
every result,
Iswitches, and the tab you pick stays for the session;Tabthere moves to the next pane as everywhere), a cell viewer, and cell, row, column and range selection with the keyboard or the mouse. The inspector's Row tab stays on the grid's column: it highlights that column and scrolls to it, andj/k(once the inspector is clicked) or a click on another column moves the grid's cell there, the grid scrolling sideways to it;Enteror a double-click shows the value whole, as in the grid. - Copy as TSV (with or without headers), CSV, JSON, indented JSON, Markdown, HTML, XML, an SQL
INlist, or SQLINSERT/UPDATEstatements (only when the target table is certain), to the system clipboard or through OSC 52 over SSH. The copied cells flash briefly once the copy is sent to the clipboard ([ui] yank_flash = "off"turns that off). - Transaction indicators in the tab bar and the status bar (open, aborted, "rollback required"), and questions before closing, quitting or disconnecting would roll back your work.
- Query plans (PostgreSQL):
Space e x(or:explain) runs the statement under the cursor asEXPLAIN (FORMAT JSON),Space e a(or:explain analyze) asEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON), through the same checks as any run (a write underANALYZEis rolled back, a read-only profile refuses it); anEXPLAIN (FORMAT JSON …)you type yourself shows the same way. A textEXPLAINyou ran stays rows, with a line under them:Pin the results (orSpace e p, or the action menu) plans that same statement again withFORMAT JSON, keeping its other options, and opens its plan; never by itself. It goes without a question only when the statement it wraps is a plainSELECTthe app would also run again for a next page (the same allowlist, its server check included: no view, no user operator or overloaded name; like that allowlist it trusts functions to be labelled honestly, so a function wrongly declaredIMMUTABLEorSTABLEcan still run while the statement is planned); anything else asks first, saying what runs again (ANALYZEruns the statement, rolled back; anEXECUTE's parameters or what a view hides are evaluated to plan it, not rolled back). It is refused when the tab ran other statements since, when the result came with other statements, or when the tab's connection, database or session changed: the plan could differ there. The Plan tab shows the plan as a tree with each node's estimated and actual rows (and loops), self and total time, a bar of its share of the whole colored by heat, and its buffers; nodes that take a fifth of the time or more are marked hot, and an estimate ten times off or more is marked with how far and which way (fewer rows than estimated under aLIMITor on the inner side of a semi or anti join are no misestimate: the node was stopped early).Entershows the selected node's detail (conditions, filters and the rows they removed, buffers, workers, loops, output). WithoutANALYZEthe numbers are the planner's estimated costs, and the tab says so.vcycles the views, and the digits pick one:1the tree;2summary cards (the slowest node and its share, the worst row estimate, disk reads and the cache hit ratio, planning and execution time) above a compact tree;3an icicle and4a flame graph (each node as wide as its share of its parent's time);5a timeline (each node's time to its first and its last row on one axis, per loop; needsANALYZE, else the estimated startup and total cost, said so);6the row flow (the rows each node passes up as a band, thick by their number, with the rows its filters removed);7a treemap (area = each node's own time);8a box diagram (boxes joined by lines; a large plan is moved around in with the selection,<>and the wheel);9the plan aspsqlprints it (written from the JSON; nothing is run again).y/Ycopy it as text or JSON. Large plans stay readable and fast: names are cut, layouts are bounded, and only what is on screen is drawn. - Charts of a result: the Chart tab of the results pane (or
cin the results,Space r c, or the action menu) draws the rows the result has fetched as vertical bars, horizontal bars or lines (braille dots); nothing is asked of the server and nothing runs again, and the chart says how many fetched rows it draws and whether the server has more (fetch more pages first to chart them). It picks its columns at first sight: a date or time and numbers give lines over time, a text column and numbers give bars (horizontal when the labels are long or many), numbers alone give lines over the first one; several numbers are several series, and one number with a text column of a few values gives a series per value.v/Vor1–3change the kind,xthe X column,sthe value columns,ba column that splits the values into series,Sa logarithmic scale (also from the action menu, and with the mouse on the chart's first lines). Rows with the same X are summed; NULLs and values that are not numbers are skipped and counted under the chart; more than 50 bars or 6 series keep the largest and sum the rest into "others". The arrows (orh/j/k/l) move a cursor over the bars or points and show the exact values and the row they come from;Enter(or a second click) shows that row in the grid;ycopies the chart's numbers as TSV,Ythe drawing as text. A result without rows or numbers, or with a single point, says why instead of drawing. Nothing is read for a chart until its tab is viewed. The series' colors are theme tokens (chart_1…chart_6). Not yet: a chart builder that writes the SQL for you.
Safety
- Statements are classified with PostgreSQL's own parser (libpg_query), not with a regular
expression:
DROP,TRUNCATE,UPDATE/DELETEwithout a realWHERE,ALTER ... TYPE,DO/CALL,COPYto or from server files or programs, server-side built-ins that act on files or backends, and text the parser rejects all ask before they run. - Environments (
dev,staging,prod, …) that profiles pick: each setsread_only, which statements need a confirmation (confirm = "destructive"or"writes"), whether results hold a server-side cursor (paging) and for how long when idle, and the spill limit, and has an optional color and a short badge (PROD) shown on its tabs, in the explorer and in the status bar. A profile's name takes its environment's color in the explorer, the tab list and quick connect (bold while connected, muted while not; its icon and state mark keep the profile's color), and so does the title of a tab on it in the tab bar. See Environments. Read-only is enforced by the server: every transaction of a read-only profile is openedREAD ONLY, so even a function called from aSELECTcannot write. EXPLAIN ANALYZEof a write is rolled back.
Keys
- A context keymap with a which-key popup after
Space, keyboard help (F1orSpace ?) with search, a:command line (Ctrl+Keverywhere) that runs commands and finds every action by name, and remapping in the config file. See docs/keybindings.md. - One action menu for the mouse and the keyboard: a right click on an explorer node, the result
grid, a tab or the editor opens it at the pointer;
Space Space(orShift+F10, or the Menu key where the terminal reports it) opens it next to the selection, andSpace t munder the active tab. It lists what you can do with the selected thing, then with the pane, each with its key. Type to filter it; when nothing in it matches, every action is searched. - The tab bar's
×turns red under the pointer (its text color alone), and a tab other than the active one takes a subtle background of its own; the tab bar's scroll marks, the result tabs, the paging arrows and the plan's view names light up under it. A result grid cell and an inspector entry under the pointer are lit faintly, and light each other: the entry of the cell's column when the cell is in the selected row, the selected row's cell of the entry's column. The pointer never moves the selection or the focus there. - The dialogs take the mouse too: the profile and tunnel forms (fields, the cursor in a text,
choices, sections, buttons), the settings, the confirmations, the password prompt, the lists
to pick from, quick connect and the command line. A button or a list row acts when it is
pressed and released on it, and not in the first moment after its dialog came on top. The pointer only
highlights a button or a row and never moves the focus or the selection, so
Enterin a confirmation still keeps; a click outside a dialog does nothing. Text inputs have no mouse selection.
Performance, held by budgets that CI enforces (crates/bench/budgets.toml,
docs/perf.md):
- One round trip for the first page of a prepared
SELECT, one for each later page and one for anINSERT, counted exactly through a latency proxy, also through an SSH tunnel. - Paging through all 4,000,000 rows of the test table stays under 96 MiB of resident memory, growing less than 16 MiB after the in-memory window is full.
- Keystroke to frame under 25 ms (p95) in a 5 MB SQL file, in a plan of hundreds of nodes and in a chart of 100,000 fetched rows.
- Idle: under 48 MiB and 1% CPU, and fewer than 0.2 wakeups a second when nothing is waiting.
- First frame under 250 ms (p95); the release binary is under 21 MiB.
Planned, in no particular order and with no dates:
- MySQL: the plan view; MariaDB as a driver of its own
- Drivers for Valkey/Redis and Elasticsearch
- TLS: passphrase-protected client keys, certificate revocation lists,
sslnegotiation=direct - A server monitor (sessions, locks, activity)
- More of vim:
gv, macros, Ex commands other than:{n}and:s(:d,:g, …) - DDL of objects the explorer does not list yet (functions on their own, sequences, types), and from the other drivers
- Table profiling from the planner's statistics, and a chart builder that writes the SQL of a chart into a console
- Comparing two plans of a statement (before and after a change), and plans of the other drivers
- Multi-hop SSH and importing hosts from
~/.ssh/config
-
Homebrew (macOS and Linux, arm64 and x86_64), in one line:
brew install dokadev/tap/datarig
or add the tap once and use the short name from then on:
brew tap dokadev/tap brew install datarig brew upgrade datarig # later, for a new releasedatarig may be submitted to homebrew-core later, so that
brew install datarigworks without the tap. The tap installs stable releases only. -
GitHub Releases: download the archive for your platform from Releases and put the
datarigbinary on yourPATH. There are archives for macOS and Linux (arm64 and x86_64) and Windows (x86_64, untested);SHA256SUMSlists their checksums. Each archive also has the licenses and the third-party notices.
datarig builds from source with the Rust toolchain. You need:
- Rust 1.93 or newer (
rust-toolchain.tomlpins 1.93.0; rustup installs it automatically). - A C compiler and libclang, for the
pg_querycrate (it compiles PostgreSQL's parser and generates bindings with bindgen). On macOS the Xcode Command Line Tools have both; on Debian/Ubuntuapt install clang libclang-dev; on Windows (untested outside CI) install LLVM and setLIBCLANG_PATHif bindgen does not find it.
git clone https://github.com/DokaDev/datarig
cd datarig
cargo install --locked --path crates/tui # installs the `datarig` binary
# or: cargo build --release (the binary is target/release/datarig)A terminal of at least 80×24 is required. A Nerd Font is optional (for the icons).
Releases follow Semantic Versioning, as 0.MINOR.PATCH until 1.0. A
patch release only fixes things. A minor release before 1.0 may change the configuration
formats: the release notes say so, and datarig migrates the files automatically, keeping a
backup of the old ones. A release candidate (vX.Y.0-rc.N), when there is one, is a GitHub
pre-release only; the Homebrew tap installs stable releases. 1.0 comes with several databases supported and a stable configuration.
datarig # open the workspace; the explorer offers "+ New connection"
datarig my-profile # connect to a profile and open a console
datarig --config path/to/config.tomlIn the workspace, press Space and wait to see what follows, or F1 for the keyboard help.
Ctrl+O picks a connection, Ctrl+E runs the statement under the cursor, Ctrl+C cancels,
:q closes a tab and :qa quits.
To try it against a local test database, start the development PostgreSQL from dev/ (see
Development) and add a profile with host 127.0.0.1, port 55432, user,
password and database datarig, or paste postgres://datarig@127.0.0.1:55432/datarig into
the new-connection form.
The config file is --config <path>, else $XDG_CONFIG_HOME/datarig/config.toml, else
~/.config/datarig/config.toml (on every OS). The app creates and edits it (comments and key
order are kept); you can also write it by hand. Passwords are never written to it.
version = 2
language = "en" # en | ko | auto
theme = "catppuccin" # see Themes below; terminal when left out
[explorer]
size_heat = "3" # colors for each schema's largest tables' estimates (3 | 5 | off)
[ui]
yank_flash = "on" # copied grid cells flash briefly (on | off)
[[connections]]
name = "local"
driver = "postgres"
host = "127.0.0.1"
port = 5432
user = "me"
database = "app"
color = "green"
folder = "dev"
password_source = "keychain" # keychain | file | command | env | prompt
[[connections]]
name = "prod-replica"
driver = "postgres"
host = "db.internal"
port = 5432
user = "readonly"
database = "app"
color = "red"
folder = "work/prod"
policy = "prod" # its environment (see Environments below)
password_source = "command"
password_command = "op read op://work/prod-db/password"
tunnel = "office" # the tunnel preset below
sslmode = "verify-full" # TLS: disable | prefer | require | verify-ca | verify-full
sslrootcert = "~/certs/company-ca.pem"
[[connections]]
name = "lab"
driver = "postgres"
host = "10.0.3.7"
user = "me"
database = "lab"
[connections.ssh] # a tunnel of this profile only
enabled = true
host = "lab-bastion.example.com"
user = "me"
auth = "agent" # key | password | agent | keyboard-interactive
[tunnels.office] # profiles that name it share one SSH connection
host = "bastion.example.com"
user = "ec2-user"
auth = "key"
key_file = "~/.ssh/prod.pem"
[policy.prod] # an environment
read_only = true
confirm = "writes"
color = "red" # one of the profile colors, or #rrggbb
badge = "PROD" # shown on its tabs and in the status bar (8 columns at most)
# paging = "no_hold" is the default: nothing stays open between pages
[policy.local]
paging = "hold" # keep the cursor (and its transaction) open while you page
paging_idle_timeout = "10s" # with "hold": close it after 10 s without a page (default 30 s)An environment is a named set of rules that profiles share: whether the profile is read-only,
which statements ask first, how results page, the spill limit, and an optional color and badge.
Pick one in a profile's form (Advanced section, Enter on Environment): the list has
"None (default rules)", every environment with its color, badge and rules, and "New
environment…", which opens the environment form and comes back with the new one picked.
The explorer's Environments section starts with Default rules (no environment): the
rules of every profile without an environment (e edits them; Enter lists those profiles;
they cannot be renamed, copied or deleted, and have no color or badge). Then it lists the
environments, each with a chip in its color (a dot with icons off), its badge and its rules in a
few words, colored by what they do: read-only in blue, "asks before every write" in the warning
color, "holds locks while paging" in the error color (the same in the profile form's list)
(the same keys as the Tunnels section:
n new, e edit, c copy, R rename, d delete, Enter or l to see the profiles that
use one). A rename takes those profiles along; deleting one that profiles use asks first and
names them. While there is no environment the section offers one row, Add presets: dev,
test, staging, prod, and the profile form's list offers the same four (a preset picked there is
written together with the profile, and only when the profile is saved):
| Preset | Rules | Color | Badge |
|---|---|---|---|
dev |
the defaults | green | |
test |
the defaults | blue | |
staging |
every write asks | orange | STG |
prod |
read-only, every write asks | red | PROD |
Nothing is written on its own: presets are added only when you ask, and once written they are ordinary environments you can edit, rename or delete. No behavior depends on an environment's name: a preset only gives the first values of its rules, color and badge, and renaming one changes nothing but its name.
In the file an environment is a [policy.<name>] table (the key from before environments had
that name, so existing files keep working) with these keys, each optional:
| Key | Values | Default |
|---|---|---|
read_only |
true, false |
false |
confirm |
"destructive" (dangerous statements), "writes" (every write too) |
"destructive" |
paging |
"no_hold", "hold" |
"no_hold" |
paging_idle_timeout |
seconds, or "90s", "5m", "1h", "off" |
"30s" |
spill_limit |
bytes, or "512MB", "2GB", "off" |
the top-level spill_limit |
color |
red, orange, yellow, green, teal, cyan, blue, indigo, purple, pink, brown, gray, or #rrggbb |
none |
badge |
up to 8 columns, no blanks around it | none |
A wrong color or badge is reported when the file is read, like any other value. The
explorer keeps the tables in the file's order. Saving from the app changes only what you
changed: an untouched table, and an untouched value in an edited one, keep their text and
comments (paging_idle_timeout = "5m" stays as written), a value set back to its default is
removed (its comment kept), and a renamed table keeps its comments and its place. Environments
written as dotted keys (policy.prod.read_only = true) or as an inline table
(policy = { prod = { read_only = true } }) stay that way, where they are.
[policy.default] holds the default rules, which every profile without an environment uses
(the built-in defaults without it: writable, dangerous statements ask, no held pages). It is not
an environment: it is the section's first row, does not count as an environment (the presets
are offered while it is the only table), and the app writes it only when you save a change in
its form (only what changed, the table made then and removed once it is empty again); a color
or badge in it stays in the file but is not shown anywhere.
A profile naming an environment that does not exist is an error of that profile, not a fallback to the default rules: its explorer line shows the environment mark in the error color, connecting is refused with a notice that names the missing environment, and a connected profile whose environment was deleted or renamed away runs nothing until another one is picked. Its form shows the name marked "(not defined)".
Data (saved queries, datarig's own known_hosts) and state (tabs, console buffers) live in the
platform's data and state directories (~/.local/share/datarig and ~/.local/state/datarig
on Linux, ~/Library/Application Support/datarig on macOS; on Windows, untested,
%APPDATA%\datarig and %LOCALAPPDATA%\datarig), or under $XDG_DATA_HOME / $XDG_STATE_HOME when set.
DATARIG_SECRET_STORE=memory keeps passwords in memory only and never touches the OS keychain.
An [editor] mode key from an older version is ignored (the editor has vim keys only) and
dropped the next time the app saves the file.
datarig's release binaries are not signed. macOS ties the "Always Allow" you give a keychain item to the program that asked, so after every upgrade it sees a new program and asks for your login password again for each saved password.
To avoid that, datarig can read and write its keychain items through Apple's own
/usr/bin/security tool, which is signed by Apple and does not change when datarig is upgraded:
[secrets]
macos_keychain_access = "security_tool" # direct (the default) | security_toolor the settings screen (Space ,, Secrets group, "macOS keychain access"), or
:set secrets.macos_keychain_access=security_tool. The change applies at once, without a
restart; turning it on asks first.
- What to expect. The first time each existing password is read or changed through
security, macOS may ask once whethersecuritymay use it: enter your login password and choose Always Allow. After that, upgrades of datarig no longer ask. - The trade-off. Any program running as your user can then read these passwords with
security(for examplesecurity find-generic-password -s datarig -g) without being asked. With the default (direct), macOS asks before another program reads them. - Limits. In this mode a password containing a line break, or longer than about 3 KB, cannot be saved (datarig says so and saves nothing); reading passwords saved earlier works.
- The items are the same in both modes (generic passwords, service
datarig, accountprofile:<id>, in your default keychain), so switching moves nothing. Passwords never appear on the tool's command line: datarig hands them over on its standard input. - Switching back to
directkeeps every password working; items saved whilesecurity_toolwas on trust onlysecurity, so macOS may ask once per item. - The setting is accepted on every platform and has no effect outside macOS.
The one-time offer. In direct mode on macOS, when a keychain access most likely waited
for you to approve it after an update, datarig asks once, after the operation that used the
keychain has finished (a connect, a test, saving a profile, ...; it never holds that operation
up, and waits for any connect in progress and any open dialog), whether to switch to
security_tool. y switches exactly as the setting does; n keeps direct; Esc or
Enter keep direct and decide later (the offer may come back at the next such prompt).
After y or n it never asks again.
- How it guesses. Each direct keychain call is timed, and datarig remembers the binary that last read a saved password directly (its version, path, size and modification time; writing a new item or removing one never asks, so it does not count). A call is taken for an update prompt when it took at least one second (or did not answer within the keychain's 10-second limit) and the binary is not the one remembered, or none is remembered yet. A slow keychain with an unchanged binary (locked, slow disk) does not count, and a fast call never does.
- Limits. If you answer the macOS dialog in under a second, the prompt is missed (the offer
comes at the next one). A keychain that is slow for another reason right after an update can
bring the offer without a prompt;
Enterkeeps everything as it is. - Never offered outside macOS, over SSH (
SSH_CONNECTION,SSH_CLIENTorSSH_TTYset), insecurity_toolmode, withDATARIG_SECRET_STORE=memory, or once answered. - Where the answer is kept.
keychain.tomlin the state directory (~/Library/Application Support/datarig/stateon macOS, or under$XDG_STATE_HOME/datarigwhen set), not in the config file. If that file exists but cannot be read, datarig does not ask and does not write over it. Deleting the file makes datarig forget the answer. - Changing the mode later works as before: the settings screen or
:set secrets.macos_keychain_access=direct|security_tool, whatever was answered.
A signed release (Developer ID) is the proper fix: macOS would then recognize datarig itself
across upgrades. Once releases are signed, datarig is meant to remove security from the items'
access lists again; until then this setting is the workaround.
One setting, theme (:set theme=<name>, or the settings screen, Space ,, where moving over
the names previews them: Enter keeps one, Esc goes back):
theme |
What it does |
|---|---|
terminal |
The default: your terminal's own colors and background (its 16 ANSI colors by role) |
dark, light, high-contrast |
Truecolor themes of datarig |
catppuccin, tokyo-night, gruvbox |
The light or the dark variant, following the terminal's background |
catppuccin-latte, catppuccin-mocha, tokyo-night-day, tokyo-night-night, gruvbox-light, gruvbox-dark |
That variant, whatever the background |
nord, dracula |
Those palettes (dark) |
| any other name | Your theme file themes/<name>.toml, next to the config file |
The terminal's background is asked once at startup (OSC 11); a terminal that does not answer
counts as dark, and the settings screen says which it found. A theme file sets some tokens and
takes the rest from extends (a built-in theme; terminal without it). Colors are #rrggbb,
an ANSI name (red, bright-black, …) or default:
# ~/.config/datarig/themes/mine.toml, used with theme = "mine"
extends = "dark"
[colors]
accent = "#89b4fa"
warning = "yellow"
[styles]
selection = { bg = "#45475a", modifiers = ["bold"] }Every token, with what it colors, is listed in docs/themes.md. A mistake in the
file (an unknown token, a bad color), an unknown name or a file named like a built-in theme is
reported with the file and line, and the app draws with terminal until it is fixed.
To start a theme file, let datarig write it:
:theme copy <from> <name>writesthemes/<name>.tomlwith every token of<from>(a built-in theme or a theme file) set to its value, each under a comment saying what it colors.:theme new <name>writes one that extends the current theme (when it is built in, elsedark) with every token commented out, showing the value it inherits.- On the settings screen,
con the theme row copies the theme shown there and asks for a name.
None of them replaces a file: a taken name, a built-in theme's name, a Windows device name
(con, nul, com1, …) or one that is not letters, digits, - and _ is refused. The new theme is then used and saved, and its path is shown.
- What asks: every statement is parsed with PostgreSQL's parser before it is sent. A
destructive or unknown statement opens a confirmation with Cancel focused; an environment with
confirm = "writes"asks for every write. - What is refused: a read-only profile refuses anything that is not a read, transaction
control or a safe session setting before sending it, and the server rejects the rest, because
every transaction is opened
READ ONLY. - What it is not: the confirmation is a guardrail against mistakes in what the text shows. Functions called from a query, triggers, views and rules are not inspected; a read-only environment is the guarantee.
- Paging leaves nothing open by default (see below): datarig never keeps a transaction, a lock or a snapshot open while you read a result, unless your environment asks for it.
A result with more rows than a page can be paged two ways, chosen per environment with paging.
Inside your own BEGIN … COMMIT block both behave the same: the result's cursor lives in
your transaction and pages from it, and ends with it.
paging = "no_hold" (default) |
paging = "hold" |
|
|---|---|---|
| After the first page | The cursor and its transaction end in the same round trip | The cursor stays open in a transaction of its own |
| Held on the server while you read | Nothing: no transaction, no lock, no snapshot | The transaction, its snapshot, and an ACCESS SHARE lock on every table the statement read |
Next page (n) |
Runs the statement again and skips the rows you have, only for a plain SELECT that is safe to repeat (said each time; the rows may differ if the data changed) |
Read from the open cursor |
| Statement not safe to repeat (a write, a volatile function, a view, …) | You see the first page, and why; to see more, page it yourself with LIMIT/OFFSET or use "hold" |
Read from the open cursor |
| Until | — | The result is read to its end, another statement runs in the tab, or paging_idle_timeout passes without a page (30 s by default; "off" never) |
Why the default changed in 0.8.0: an ACCESS SHARE lock held by an idle result makes a
deploy's ALTER TABLE wait, and every query that comes after the ALTER TABLE waits behind it.
A held transaction also keeps vacuum from cleaning up. Use "hold" for a local database, or
when you walk a large result page by page (with no hold each page runs the statement again and
skips the rows before it, which costs more the deeper you go). A row count (#) never keeps
anything open of its own.
MySQL 8.0, 8.4 and 9.x (tested on 8.0.45, 8.4.11 and 9.7.2). MySQL 5.7 and older are refused with their version. MariaDB 10.6 and newer is a best effort: it connects and runs statements, and is tested only lightly.
What works
-
Connecting directly or through an SSH tunnel or tunnel preset (the driver talks over the tunnel's channel; no local port is opened),
caching_sha2_passwordandmysql_native_password, and the test connection. -
Running statements: one per request (datarig splits scripts itself,
DELIMITERincluded; the client never asks the server to run several statements from one request, andLOAD DATA LOCALis off). The editor and the risk checks read the text in the session's sql mode (ANSI_QUOTES,NO_BACKSLASH_ESCAPES). -
Values as the server writes them:
DECIMALexact, unsigned integers in full,BITasb'…', binary values as0x…,JSON, dates and times with their fractions,TIMEas an interval (negative, or over 24 hours), zero dates (0000-00-00) as text,ENUM/SETas text,GEOMETRYas bytes. -
Rows affected, the last insert id, warnings and the server's info line in Messages; errors as the
mysqlclient shows them (ERROR 1146 (42S02): …). -
Cancel (
Ctrl+C):KILL QUERYfrom a connection of its own, neverKILL. ASLEEP()that is stopped returns 1, as in themysqlclient. A cancel the server takes is waited for, however long the statement then takes to roll back. A statement that runs as another account (in aSQL SECURITY DEFINERroutine; stopping it needsCONNECTION_ADMIN) refuses the cancel, even after a few tries: if it sends no rows, it runs on to its end whatever datarig does, and the tab says so and keeps waiting (closing the tab would not stop it: ask a DBA or the routine's owner toKILLit). datarig closes the tab's connection instead of reading on, and the next run connects again, in three cases:- the server refuses the cancel and the statement keeps sending rows;
- the cancel cannot reach the server (its connection does not open: the network, the login);
- the server takes the cancel and the statement still keeps sending rows more than 10 seconds later.
Whether the statement then stops is the server's: it may run on, and outside a transaction block commit what it changes. An open transaction is rolled back once the server ends the session.
-
Transactions as the server reports them after each statement; a statement that commits your open transaction by itself (DDL and others) is noted in Messages.
-
The explorer: the server's databases right under the profile (a MySQL database is the schema), their tables and views with the server's estimates (
~rows · size), and a table's structure (columns, keys, indexes, constraints, triggers); completion;:use dband the database picker (Space c d). Check constraints need MySQL 8.0.16 or later, a user with grants on some columns only sees those columns, triggers are listed only with theTRIGGERprivilege (without it the group says it is not known),lower_case_table_names = 2is not tested, and MariaDB needs 10.5 or later (JSON_ARRAYAGG). -
Show DDL:
Don a table, view or trigger (or its menu), or:ddl [kind] name(nameordb.name, in the tab's database;kindistable,view,procedure,function,triggerorevent, needed only when one name names several), opens the server's ownSHOW CREATEtext for tables, views, triggers, procedures, functions and events, named by kind (db.table,db.table.trigger,db.routine(),db.event (event)). MySQL has no DDL of an index of its own:Don an index row shows its table's. A trigger's, routine's or event's text is wrapped asmysqldumpwrites it (DELIMITER ;;…;;/DELIMITER ;). An object made under anothersql_modethan the connection's default starts with a-- sql_mode: …line: its body is read in that mode. The server writes the strings of a table's or a view's text with backslash escapes whatever the mode, and says so in a first line when there is a backslash.oopens the text in a console in the object's database, with every string written as that console'ssql_modereads it (NO_BACKSLASH_ESCAPESor not), so it makes the same object there; the DDL tab keeps the server's text. Copied elsewhere, run it where the strings read as written. MySQL itself cannot make an expression (an expression default, a check) with a quote in a string underNO_BACKSLASH_ESCAPES. TheDEFINERis kept, so running it again as another user needsSET_USER_ID(MySQL 8.0) orSET_ANY_DEFINER(8.2 and later), as withmysqldump. A routine you may run but not see says its definition is hidden. Reading waits at most 2 s on another session: an object whose table is locked shows "locked", andrreads it again. -
Copy as SQL
INSERTandUPDATE: backtick names, MySQL's literals (bytesX'…', bitsb'…'), generated columns left out. A string with a backslash or a NUL is written as its UTF-8 bytes (_utf8mb4 X'…'), so the statements read the same in any sql mode (NO_BACKSLASH_ESCAPES,ANSI_QUOTES); aTIMESTAMPis written with the offset of the session's time zone ('2024-10-27 00:30:00+00:00').UPDATEneeds the table's primary key in the result. A copy is refused, naming the column, where a value would not be written whole: aFLOAT(the server sends it rounded to about six digits), aTIMESTAMPwhile the session's time zone is not a fixed offset (SET time_zone = '+00:00'first), text while the session's results are notutf8mb4(after aSET NAMES latin1, orutf8mb3), and anAUTO_INCREMENTvalue of 0 (anINSERTtakes it as the next number). A whole result is refused when it was read while a procedure, anEXECUTEor an executable comment ran, or before the session said its values, and when aSET time_zoneorSET NAMESran while it was read: run the statement again. -
The keys copy as SQL needs are read for every database when the connection opens (and again after DDL run in datarig), so a table created elsewhere later needs
ron the connection in the explorer before its rows can be copied. Column names compare without case, and database and table names too whenlower_case_table_namesis 1 or 2, as the server compares them; 2 (macOS, Windows) is not tested. A temporary table that shadows a table of the same name is not told apart from it. -
The row count (
#), key marks, hints, charts, and copying rows in every other format.
What does not work yet
- The plan view (
EXPLAINruns and shows its text). - A
USEtyped in a console is not followed by the header and completion; use:use. - The estimates are the server's (
TABLE_ROWS), which can be far off for InnoDB. A view whose table was dropped is listed without columns, and a database that does not exist or that you may not read shows as empty. On a server with tens of thousands of tables the explorer's catalog and key reads may hit their 10-second limit; copy as SQL then says the keys could not be read.
Paging and locks. As on PostgreSQL, nothing is held open while you read a result by default.
The query session keeps sql_select_limit at a page and one row, so the first page is read to
the end of the statement and its metadata lock is released; the next page runs the statement
again and skips the rows you have (only for a statement that is safe to repeat; one that names a
view is checked on the server first). SHOW and DESCRIBE are read whole. With
paging = "hold", and inside your own transaction, a result is read whole into datarig's local
spill (up to its limit) and the server is free at once. So is a locking read (FOR UPDATE,
FOR SHARE, LOCK IN SHARE MODE): under the page limit it would lock fewer rows than it asks
for. The explorer and completion read in autocommit with lock_wait_timeout = 2 and a
10-second max_execution_time: behind a waiting ALTER TABLE they give up after two seconds
instead of queueing behind it. A read that opens a table's definition (SHOW CREATE TABLE)
takes a high-priority shared metadata lock (SHARED_HIGH_PRIO) for the moment it reads: MySQL
grants it past a waiting ALTER TABLE, and it is gone once the read has answered.
Read-only profiles. Two layers, as on PostgreSQL: datarig refuses everything that is not a
read before it is sent, including CALL, executable comments (/*! … */), functions that are
not built in, and what MySQL's own read-only still allows (FOR SHARE, LOCK TABLES,
GET_LOCK(), SET GLOBAL, …); and the session runs with transaction_read_only = ON, checked
after every statement (a session that loses it is closed). Unlike PostgreSQL, MySQL lets a
statement turn its read-only off, so the server's layer depends on datarig's: for a guarantee
that does not, use an account with SELECT privileges only. Views, triggers, generated columns
and DEFAULT expressions are not inspected; the server still refuses their writes.
Passwords on a direct connection. A MySQL profile uses TLS when the server offers it
(PREFERRED, the mysql client's default; see TLS). A caching_sha2_password login the
server has not cached (the first one, or after a restart) needs the password itself:
- Over TLS it goes inside TLS, as the mysql client sends it, but to another machine only when
the server's certificate was checked (
VERIFY_CA,VERIFY_IDENTITY), or the connection goes through an SSH tunnel, or the profile allows key retrieval (below). OverPREFERREDorREQUIRED(without a CA file) to another machine the certificate is not checked, so whoever is in between could answer in the server's place: the login fails with an explanation before anything about the password is sent. - Unencrypted (a server without TLS, or
DISABLED), the password goes encrypted with the server's RSA public key. Asked for over the same plain connection, that key could come from anyone in between, who could then read the password, so on a direct connection to another machine datarig does not ask for it and the login fails with an explanation. Name the server's key file in the profile (server_public_key_file, the PEM ofSHOW STATUS LIKE 'Caching_sha2_password_rsa_public_key'), or allow asking for it (allow_public_key_retrieval = true). A profile that names a key file and no TLS mode stays unencrypted (DISABLED), so its login works as before.
A server on your own machine, or one reached through an SSH tunnel, gets the password either
way. A mysql_native_password login over plain TCP can be cracked offline from a capture
whatever the key, and so can a weak password from any captured login exchange (someone in
between can also choose what the server's side of it says): use VERIFY_IDENTITY (or an SSH
tunnel) for any server that is not on your own machine. ERROR 1045 may also mean the account
requires TLS (REQUIRE SSL) on a connection without it: after a typed password is refused,
datarig shows the error instead of asking again, and connecting again asks again.
PostgreSQL and MySQL connections are encrypted with TLS 1.2 or 1.3 (rustls with ring) as the
profile's SSL mode says, in each database's own words. The profile form's Advanced section
has the mode (a list), the CA file, the client certificate and its key; in the
config file they are sslmode, sslrootcert, sslcert and sslkey, and a PostgreSQL URL
takes the same four as parameters (postgres://me@db/app?sslmode=verify-full&sslrootcert=...).
| PostgreSQL | MySQL | Encrypted | What is checked of the server's certificate |
|---|---|---|---|
disable |
DISABLED |
never | – |
prefer |
PREFERRED |
when the server offers TLS | nothing |
require |
REQUIRED |
always | nothing; with a CA file, as verify-ca |
verify-ca |
VERIFY_CA |
always | its chain, against the CA file (needed) |
verify-full |
VERIFY_IDENTITY |
always | its chain and that it is the profile's host |
prefer/PREFERREDgoes on unencrypted only when the server says it has no TLS. A server that offers TLS and then fails the handshake is an error, never a plain connection.- The CA file is the only trust: with one, only its certificates are trusted (a server's own
self-signed certificate copied to your machine works too). Without one,
verify-full/VERIFY_IDENTITYchecks the certificate against the certificates your system trusts: the macOS keychain's trust settings (datarig reads no keychain item), Windows' certificate store, the system's certificate files on Linux.sslrootcert = "system"says so explicitly. - The name checked is the profile's host, also through an SSH tunnel (never
127.0.0.1). An IP address is checked against the certificate's IP addresses. - The client certificate and key are PEM files (PKCS#8, RSA or EC keys). A key protected by a passphrase is refused with how to make an unprotected copy.
- PostgreSQL's SCRAM login binds itself to the TLS connection (
SCRAM-SHA-256-PLUS) where the server offers it, so a server in between that passes the login on is found out. One that answers itself can still ask for the password (cleartext or MD5) unless the certificate is checked: useverify-full, orchannel_binding=requirein the URL. Cancel requests (PostgreSQL) andKILL QUERYconnections (MySQL) are encrypted as the session is. - Defaults, unchanged for what connected before: a PostgreSQL profile without
sslmodeisdisable(the form writespreferfor a new one); a MySQL profile without one isPREFERRED, except one that names a server public key file (DISABLED, see MySQL). In a MySQL profiledisableandprefer(what older versions of the form wrote there) count as no mode: writeDISABLEDto turn TLS off. A mode the driver does not know is an error of the profile. - Each connection says what it got:
Connected to prod · TLS 1.3, server verified(orcertificate not checked,CA checked), a shield andTLS 1.3in the editor's connection line, and the same in the form's test. A connection to another machine without TLS (not through a tunnel) says it is not encrypted, for both databases. - Failures say which: the server has no TLS, the certificate is not trusted, is for another name, has expired or is not valid yet, is not meant for a server, or the server refused the client certificate; a file that cannot be read names the file.
Tested: every mode against PostgreSQL 17 and MySQL 8.4 with certificates made for the tests
(trusted, another CA's, another name; client certificates accepted and refused on PostgreSQL),
through a dialer as a tunnel uses one (cancel requests and KILL QUERY included), and
PostgreSQL verify-full through a real SSH bastion; in-process tests for expired and
not-yet-valid certificates, other purposes, a self-signed certificate as its own CA file, and
TLS 1.2. The system's certificates are tested refusing a CA they do not have; a server they do
trust, and sslrootcert = "system", are not tested end to end. Not tested either: real company
servers, PgBouncer with TLS between datarig and the pooler, Windows. Limits: no
passphrase-protected keys, no certificate revocation lists, no sslnegotiation=direct
(refused), no TLS 1.0/1.1, RSA keys (the server's and the client's) under 2048 bits are refused; a MySQL URL takes no TLS parameters (use the fields). On macOS,
verify-full against the system's certificates follows Apple's rules, which refuse server
certificates valid for more than 825 days from a CA you added yourself: name that CA as the
profile's CA file.
Each profile can reach its database through one SSH bastion. datarig opens the tunnel itself
(pure Rust, no ssh binary) and dials every connection of the profile through direct-tcpip
channels; nothing else is requested from the server. The key file must not be readable by
others, as OpenSSH requires. The key's passphrase or the SSH password uses the same sources as
database passwords. Keepalives detect a dead tunnel, and the explorer marks the profile until
the next use reconnects.
The profile form's SSH section picks the tunnel: off, a tunnel preset, or this profile
only (its own bastion fields, [connections.ssh]). Enter (or a click on the value) lists
them with each preset's bastion, filtered as you type. Its last entry, New SSH tunnel… (when
the config file can be written), opens the tunnel form: once that is saved, the profile form
comes back with the new preset picked.
- Tunnel presets (
[tunnels.<name>], the profile names one withtunnel = "<name>") live in the explorer's Tunnels section, below the connections:nmakes one,eedits it (its name too: the profiles that name it follow),ccopies it,ddeletes it,ttests it, andEntershows the profiles connected through it now (one that names it but is not connected, or is still connecting, is not listed; a preset nobody is connected through has nothing to open). Each preset shows whether its connection is closed, opening, open (and how many profiles are on it) or lost (and why) by the color of its icon (muted, a spinner, the success color, the error color;○,●and✕with icons off), and otherwise how many profiles name it. - Profiles that use the same preset at the same time share one SSH connection: the first one opens it (one login, one host key question, one secret prompt, for every profile waiting), the others take it as it is, and it closes when the last of them disconnects. When it is lost, every profile on it says so, and each connects again on its next use.
- A preset's secret (the key's passphrase or the password) is kept once, under the preset's own keychain or secrets-file account, which a rename keeps. Deleting a preset asks first, lists the profiles that use it and removes its saved secret.
- A preset changed while profiles are connected through it: they keep the connection they have until they connect again; the next connect uses the new settings.
- A profile whose preset does not exist (or that names a preset and has its own tunnel turned on) does not connect and says why; it never connects directly instead.
- "Save as tunnel preset" (
Ctrl+Bin the form's own tunnel) makes a preset of the profile's own tunnel when the form is saved: the settings move to the preset and the profile names it; its stored secret is copied to the preset (and read back) before the old copy is removed. A secret that cannot be read stays where it was and is asked for at the next connect. - Testing a preset opens its SSH connection and then one
direct-tcpipchannel to each database address of the profiles that use it (opened and closed again; nothing is sent to a database). - The app never rewrites the config on its own: presets change only when you save a form, rename or delete one.
The workspace layout:
| Path | What |
|---|---|
crates/core |
UI-free core: driver interface, SQL tooling and the safety classifier, profiles, secrets, config, i18n |
crates/drivers/postgres |
the PostgreSQL driver |
crates/drivers/mysql |
the MySQL driver (in part: see MySQL) |
crates/ssh |
SSH tunnels |
crates/tls |
TLS for the database connections (rustls) |
crates/tui |
the terminal UI and the datarig binary |
crates/bench |
benchmarks and the CI performance budgets |
vendor/tokio-postgres |
a patched tokio-postgres (per-column result formats, pipelining); see vendor/README.md |
vendor/mysql_async |
a patched mysql_async (connects over a stream the driver opens, so a tunnel needs no local port; never asks for multi-statements or LOAD DATA LOCAL); see vendor/README.md |
locales |
the UI text catalogs (en.toml, ko.toml) |
dev |
Docker Compose with PostgreSQL, MySQL, PgBouncer and an SSH bastion for the tests |
docs |
architecture, key bindings, performance |
Unit and flow tests need nothing else:
cargo test --workspaceThe integration tests need the services of dev/; without their environment variables they
print SKIPPED:
cd dev
docker compose up -d
docker compose --profile pooler up -d pgbouncer
sh ssh/make-fixture.sh # prints the fixture directory
docker compose --profile ssh up -d --build ssh-bastion
cd ..
export DATARIG_TEST_PG_URL=postgres://datarig:datarig@127.0.0.1:55432/datarig
export DATARIG_TEST_POOLER_URL=postgres://datarig:datarig@127.0.0.1:56432/datarig
export DATARIG_TEST_SSH_BASTION=127.0.0.1:52222
export DATARIG_SSH_FIXTURE=$PWD/dev/ssh/.fixture
export DATARIG_REQUIRE_PG=1 DATARIG_REQUIRE_POOLER=1 DATARIG_REQUIRE_SSH=1 # fail instead of skipping
cargo test --workspace
# the driver's suite again with every connection going through a dialer, as through a tunnel
DATARIG_TEST_DIAL=tcp cargo test -p datarig-driver-postgres --test integration_pgThe performance budgets (needs tmux and the database above):
cargo build --release -p datarig-tui -p datarig-bench
./target/release/datarig-bench budgetBefore sending a change: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and the tests. Snapshot tests use insta; after an intended
rendering change, INSTA_UPDATE=always cargo test -p datarig-tui rewrites the snapshots, and
the diff is part of the review. docs/keybindings.md is generated:
DATARIG_BLESS=1 cargo test -p datarig-tui --test keybindings_doc. See
CONTRIBUTING.md.
datarig is dual-licensed under either of
- the Apache License, Version 2.0 (LICENSE-APACHE)
- the MIT license (LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in datarig by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Third-party code: vendor/tokio-postgres keeps its own MIT/Apache-2.0 licenses, and the C code
built in through pg_query (libpg_query, PostgreSQL, protobuf-c, xxHash) has its notices in
vendor/licenses/pg_query/. scripts/third-party-notices.sh (cargo-about) writes
THIRD-PARTY-NOTICES.html with the licenses of every crate in the binary; CI builds it as an
artifact. cargo deny check keeps the dependencies to permissive licenses.