Skip to main content

The catalog reader: what it reads, what it never touches

The audit suite works from your database's live schema rather than from your migration files. That means SQLens connects to a database you probably care about a great deal, and this page is the checkable version of the promise it makes about that: it reads catalog metadata, it writes nothing, it takes no locks, and it bounds itself.

What it reads

The reader issues a fixed battery of queries against system catalog relations. The list is complete — there is nothing else — and a test over a recorded run enforces it, so this page cannot drift away from the code without something going red.

On PostgreSQL it reads pg_class, pg_namespace, pg_attribute, pg_attrdef, pg_index, pg_am, pg_opclass, pg_constraint, pg_type, pg_collation, pg_inherits, pg_depend, pg_extension, pg_settings and pg_roles.

On MySQL it reads information_schema.SCHEMATA, .TABLES, .COLUMNS, .STATISTICS, .TABLE_CONSTRAINTS, .KEY_COLUMN_USAGE, .REFERENTIAL_CONSTRAINTS and .PARTITIONS.

What it never touches

  • No user table is read. Not to count rows, not to sample values, not as a join in a catalog query. Your data is not part of the input.
  • No write. With exactly one exception, described below.
  • No lock. No LOCK TABLE, no SELECT … FOR UPDATE, no FOR SHARE. A tool sent to look for lock problems must not be the one that causes them.
  • No DDL. Nothing is created, altered or dropped.

The single exception is the read-only probe. The reader opens its own connection, seals it read-only, and then attempts one write and requires the refusal. That probe runs inside a savepoint and is rolled back either way, so it leaves nothing behind. It exists because a session-wide read-only flag can report itself as set while writes go through — measured, on PostgreSQL — and a promise that rests on an unverified setting is not a promise. If the write succeeds, the reading does not happen at all.

The privileges it needs

SQLens is designed to run as an ordinary, least-privileged account. Neither engine needs a superuser.

PostgreSQL

CREATE ROLE sqlens_reader LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE your_database TO sqlens_reader;
GRANT USAGE ON SCHEMA public TO sqlens_reader;

That is enough for the core of an audit: pg_class, pg_attribute and pg_index are readable by any role on a stock PostgreSQL, so tables, columns, indexes, constraints and types all come back. Two things do not, and both end as named skips rather than errors:

-- Optional. Without it, no check may reason about table size or row estimates.
GRANT pg_read_all_stats TO sqlens_reader;

-- Optional. Without it, settings that require elevated access are invisible — and they
-- are ABSENT from pg_settings rather than refused, so nothing in a reading would
-- otherwise reveal that they were missing.
GRANT pg_read_all_settings TO sqlens_reader;

MySQL

CREATE USER 'sqlens_reader'@'%' IDENTIFIED BY 'change-me';
GRANT SELECT ON your_database.* TO 'sqlens_reader'@'%';

The SELECT grant is not optional here, and the reason is worth knowing: MySQL filters information_schema by privilege, silently. An account without SELECT on a table does not see that table's row — the query succeeds and returns nothing. An under-privileged audit therefore comes back clean, empty and complete-looking, which is why SQLens probes what it can see before reading and reports an invisible database as a finding rather than as an empty result.

Managed databases

On RDS, Aurora, Cloud SQL and Neon, part of the catalog is unavailable without access nobody hands out. That is the ordinary case, not a fault, and SQLens is built for it: it finishes the run and names what it could not read.

PlatformExpect to see
Amazon RDS / Aurorapg_read_all_settings unavailable → restricted server settings skipped
Google Cloud SQLstatistics and some settings restricted; performance_schema may be off on MySQL
Neonsuperuser-only catalogs unavailable; the core catalog reads normally
Any MySQL hostperformance_schema disabled or not granted → no live statement instrumentation

A skip is a finding, not noise. It appears in the report as an undetermined result with a named reason, it is counted in the summary, and under strict_undetermined it fails the run. What it never does is disappear: a check that could not run is never reported as a check that passed.

Configuration

Every key below lives under sqlens.catalog.

KeyDefaultWhat it does
session.statement_timeout5000Milliseconds a single catalog query may take. Must be positive; zero would mean "wait forever", which is the harm the bound exists to prevent.
session.lock_timeout1000Milliseconds the reader waits for a lock it never intends to take. A bound against waiting, not against locking.
session.idle_in_transaction_timeout5000Milliseconds a stalled read transaction may sit open. This is the shape that holds a snapshot open and blocks VACUUM on PostgreSQL.
session.application_namesqlensThe name the reader appears under in the server's activity view. An unidentified session holding a connection on production is one somebody eventually kills blind.
budget_ms5000Milliseconds a whole reading may take before it reports budget_exceeded. Not a second statement timeout: a hundred fast queries can be inside every per-statement bound and still hold a connection for half a minute. Exceeding it is a named undetermined, never an abort.
schemas[]The schemas to audit. Empty means the session's own resolved scope — the real search_path on PostgreSQL, the current database on MySQL — never an assumption that everything lives in public. A schema that does not exist is a configuration error naming it, not an empty audit.
table_prefixnullOverrides the connection's own Laravel prefix. null means "not overridden"; '' is a real answer meaning "this project has no prefix".
prefix_scopelooseloose reads everything and marks what is not the project's; strict reads only prefixed objects and names what it left out. Loose is the default because omitting objects is the worse way to be wrong: strict would silently drop a project's own unprefixed legacy tables.
report_partitions_individuallyfalseWhether each partition is its own object. Off by default: a partitioned table is one object to a rule, and reporting it as n multiplies every finding by the partition count.
include_extension_objectsfalseWhether objects a database extension owns are audited. Off by default — PostGIS alone installs tables whose design nobody in your project chose. Ownership is read from pg_depend, never from a name list.
extensions.allow[]Extensions whose objects are your business, named one by one. Per-extension rather than one switch: owning your citext domains should not mean taking PostGIS's tables with them.

include_extension_objects and extensions.allow have no effect on MySQL, and that is a fact about the engine rather than a gap: a MySQL plugin is server code and owns no catalog objects, so there is no ownership edge to filter on.

Reading the result

A reading reports its own completeness. complete means nothing in scope went unread; partial means something did, and the skips say what. Two things deliberately do not make a reading partial:

  • A deliberate exclusion — an extension's objects, a schema you scoped out. Nothing went unread; a scope was chosen.
  • A comprehension limit — an index SQLens read completely but cannot compare, such as a partial index or one over an expression. The object is in the snapshot; what it cannot support is a rule reasoning about it, which is a question about the rule. Those are ordinary enough that counting them would make partial mean nothing.