Key learnings
CLI design with Typer, including subcommands, global options, and config-file precedence
Backend-agnostic querying through SQLAlchemy rather than database-specific SQL
Running the same test suite against PostgreSQL, MySQL, and SQLite with a parameterised fixture
Integration testing against real database instances using Testcontainers
Description
DBGrep is a command-line tool for finding a value in a database when you do not know which table it lives in. Point it at a connection string, give it something to look for, and it searches across the schema and prints every table that matched (the database equivalent of grep -r).
It has two commands. inspect reports the structure of a database: tables, columns, primary and foreign keys, and indexes, either for everything or scoped to one table with --in.
search looks for values, with exact matching, LIKE patterns, and numeric greater/less comparisons that can be combined and are ANDed together. It can run across the whole database, or be scoped to a table or a single column with --in users.name. Global options such as the connection and output format come before the command, and command options come after it:
Both commands render through the same formatter, which prints a table, JSON, or YAML, optionally syntax-highlighted, paged, or written straight to a file.
The connection can live in a JSON config file instead of a flag. It needs a db_url key and can optionally set defaults for the formatter:
Everything goes through SQLAlchemy with no backend-specific SQL anywhere in the query layer, so the same commands work against PostgreSQL, MySQL, and SQLite.
Reflection
I worked on this with Tobias and two others, and my part was mostly the surface: the CLI entry point, the formatter, and the configuration handling, plus most of the documentation.
The formatter turned out to be the more interesting half. It started as "print a table" and accumulated real requirements: three output formats, syntax themes for the structured ones, a pager for results too long for a screen, and an option to write to a file instead. Each of these arrived as a separate flag, and the naive version ended up with format decisions scattered across every call site. Pulling the configuration into the formatter's constructor, so callers just hand it data, was the refactor that made the rest of the features cheap to add.
Configuration precedence was subtler than expected. A connection can come from a JSON config file or a --db-url flag, and the formatting options can come from either as well. Getting the layering right (file provides defaults, explicit flags override them, and mutually exclusive options fail loudly rather than silently picking one) took more iterations than the feature deserved, mostly because Typer's default values make "the user didn't pass this" and "the user passed the default" hard to tell apart.
The bug I remember best was a one-word fix. Combining --like "%john%" with --greater 50000 was ORing the two conditions instead of ANDing them, so a filter that should have narrowed the results widened them instead. It is the kind of mistake that produces plausible-looking output and no error, which is exactly why the test suite mattered.
That test suite is the part of the project I would point at. A database_url fixture runs every test against PostgreSQL and MySQL in Testcontainers and against SQLite in a temp file, so each test is written once and executed three times. It caught genuine divergences between backends that would otherwise have shipped, and it is the reason the "no database-specific code" rule held up rather than quietly eroding.


