Skip to main content

The canonical form

Every rule in SQLens works on one data structure: the canonical statement. It is the single allowed abstraction over the quirks of Laravel's schema grammar, and from the moment a rule receives it there is no legitimate reason to look at raw SQL again. This page describes that shape: what the canonical form guarantees, what it deliberately does not, and the rules that keep it that way.

The stable extension points from 1.0 are the rule contract (Pushery\SQLens\Contracts\Rule) and the driver registry — see the project's governance document. CanonicalStatement and StatementKind are the internal normalized shape a rule is handed through its Subject; that class surface is not itself a frozen 1.0 API and may still change before 1.0. Build rules against the contract, and read the canonical form as the data it hands you.

Why it exists​

Laravel's grammar output drifts between framework majors — the same migration can emit different quoting, different whitespace, a different statement bundling. A rule that matched on that raw text would break on an upgrade that changed nothing about the migration's meaning. Canonicalization erases that drift once, centrally, so a rule can match on what a statement does and what it acts on instead of how the grammar happened to spell it today.

The shape of a canonical statement​

A canonical statement is an immutable value object with these fields:

FieldMeaning
canonicalSqlthe normalized SQL text — one statement, formatting erased
statementKindwhat the statement does (see below), or null until classified
targetsthe schema objects it acts on, or null until classified
transactionthe transaction context the statement runs in
originprovenance only — the migration file and the statement's index
formVersionthe version of the canonical form this artifact was produced with

isClassified() reports whether the kind and targets are filled. They are nullable on purpose: an unclassified statement reads as null, never as an empty, harmless-looking default a rule might mistake for "nothing here".

What is guaranteed​

The canonicalization runs an ordered pipeline of stages, each one literal-, comment- and dollar-quote-safe. Together they guarantee:

  • Statement boundaries. A raw batch is split into single statements, in execution order. A semicolon inside a string literal (a PostgreSQL escape string E'…' included), a comment (nested ones on PostgreSQL included), a PostgreSQL dollar-quoted body, or a MySQL routine body does not split. Rules never split on a semicolon themselves.
  • Whitespace. Runs of whitespace collapse to a single space, line endings unify to \n, and edges are trimmed. Whitespace inside a string literal, a comment, or a dollar-quoted body is kept byte-exact — a default value 'a b' is payload, not formatting.
  • Identifier quoting and case. "users", users, and — under MySQL — `users` collapse to one canonical spelling, and case is folded per the engine's rule (PostgreSQL folds unquoted identifiers to lower case; MySQL keeps them). A quoted mixed-case identifier such as "Users" stays distinct, because under PostgreSQL it names a different object. A name the engine reserves, such as group or user on PostgreSQL and groups or rank on MySQL, keeps its quotes, so it still parses where a suggestion writes it.
  • Keyword casing. Keywords are folded to upper case, so select, SELECT, and SeLeCt are one keyword. Identifiers are never touched by this — upper-casing "user" would change which object it names.
  • Statement kind. Every classified statement carries one kind. The full set is create_table, alter_table, drop_table, drop_schema, truncate_table, create_index, create_fulltext_index, create_spatial_index, drop_index, add_constraint, add_primary_key, add_foreign_key, drop_constraint, add_column, alter_column, drop_column, rename, rebuild_table, reindex, refresh_materialized_view, session_setting, dml, ddl_other, and unknown. rebuild_table is a maintenance statement that rewrites a whole table, PostgreSQL's VACUUM FULL and CLUSTER, or MySQL's OPTIMIZE TABLE. reindex is PostgreSQL's REINDEX INDEX or REINDEX TABLE, and refresh_materialized_view its REFRESH MATERIALIZED VIEW. unknown is a statement recognized and not modeled, such as SELECT 1, an ANALYZE after a backfill or a plain VACUUM. A statement that is not recognized at all, a REINDEX DATABASE or MySQL's REPAIR TABLE among them, has no kind: it is undetermined, and so is its whole migration.
  • Targets. Each target carries an object type (table, index, constraint, column) and a normalized identifier whose schema and name stay separate. A statement can have several targets — a CREATE INDEX … ON … yields the index and its table. The target set is ordered deterministically. An unquoted name that is also a keyword, such as type, comment or date, is a target wherever the engine accepts it unquoted, and it is written quoted, so type and "type" are one target. A word the engine reserves never names one.
  • Transaction context. Each statement records whether it runs in the migrator's implicit transaction, in an explicit one, in none, or is undetermined — resolved once from the migration flag, the engine's DDL-transaction behavior, and the transaction markers the driver declares.
  • The connection's own reading on MySQL. Two settings of sql_mode change how MySQL reads a statement: under ANSI_QUOTES a double quote names an object instead of opening a string, and under NO_BACKSLASH_ESCAPES a backslash inside a string is an ordinary character. When a connection's modes set either one, or ANSI, which includes ANSI_QUOTES, each statement captured on it is translated into the default reading with the same meaning before anything else reads it: a double-quoted name becomes a backtick-quoted one, and a backslash inside a string literal is doubled. Laravel's schema builder writes neither form, so only raw statements change.

What is NOT guaranteed​

The canonical form is a normalization, not a rewrite. It deliberately does not:

  • Pretty-print. Canonicalization collapses noise; it does not reformat SQL into a house style. That is the format suite's job, not this layer's.
  • Rewrite semantics. It never changes what a statement means — no reordering of clauses that could alter behavior, no substitution of equivalent syntax.
  • Resolve schema qualification. public.users never collapses to users. Treating them as the same object would be an assumption about the search_path, and that is configuration owned by the audit suite, not a guess this layer may make. The schema and the name are kept as separate, comparable parts precisely so the schema dimension survives.
  • Follow a MySQL sql_mode the connection does not declare. The reading comes from the connection's modes. Without them the session runs under the mode Laravel sets from strict, which holds neither setting, or under the server's own when strict is unset, and a server that sets ANSI_QUOTES or NO_BACKSLASH_ESCAPES for itself is not asked. Its raw statements are read the default way: a name written in double quotes reads as a string, and a literal that ends in a backslash runs on into the next one.

Three-valued, never silent​

Results are three-valued. When a statement cannot be canonicalized or classified safely, the layer produces a named failure carrying a reason — an unterminated literal, an over-qualified identifier, a statement form the driver does not recognize. It never swallows the problem and returns a plausible-but-wrong result. A caller turns that failure into an undetermined finding with the named reason, so a check that could not run is reported, not hidden.

Rules never match on raw SQL​

The classification exists so a rule has an alternative to the raw text. Reach for statementKind and targets; never regex canonicalSql for grammar patterns.

Bad — this reads the grammar's spelling and breaks the moment it drifts:

use Pushery\SQLens\Canonical\CanonicalStatement;

function looksLikeAnIndex(CanonicalStatement $statement): bool
{
return str_contains($statement->canonicalSql, 'CREATE INDEX');
}

Good — this reads the classified fact and is immune to formatting and quoting:

use Pushery\SQLens\Canonical\CanonicalStatement;
use Pushery\SQLens\Canonical\StatementKind;

function looksLikeAnIndex(CanonicalStatement $statement): bool
{
return $statement->statementKind === StatementKind::CreateIndex;
}

This is not a convention to remember — it is enforced. An architecture test fails the build if a rule reaches back to the pre-canonical raw form or imports the framework's grammar internals.

Canonicalization never touches a database​

The layer is pure text work. It opens no connection, starts no transaction, and runs where the database is not allowed to exist — driver resolution reads only the connection configuration, never a connection. This is a principle, not a performance note, and it is enforced: an architecture test forbids the layer from using the query connection, and a behavioral test canonicalizes the whole corpus against an unreachable database and stays green.

Versioning, fingerprints, and drift​

The canonical form is explicitly versioned. A fingerprint is a stable hash over the whole form — the version, the kind, the ordered target set, the transaction mode, and the SQL — so a change a rule can see (a different kind or target under identical SQL) changes the fingerprint.

A version bump means the canonical form itself changed shape, so two statement fingerprints compare only under the same version. Anything that reads a stored canonical statement back reads its version first and reports one it cannot interpret rather than comparing across versions blindly.

A committed baseline keeps matching across a bump. Its entries are keyed by the rule, the location and the statement each finding is about, and the statement part is the same hash with the version held at a fixed value. A bump therefore changes a finding's identity only where the statement's own canonical form moved: its SQL text, its kind, its targets or its transaction context. Every other accepted finding stays accepted. Regenerating a baseline because of a form bump would do harm: it re-accepts whatever is reported at that moment, new findings included.

See also​