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 GOVERNANCE.md. 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 comment, 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.
  • 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, truncate_table, create_index, drop_index, add_constraint, drop_constraint, drop_column, rename, dml, ddl_other, and unknown.
  • 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.
  • 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.

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.

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. That fingerprint is the identity a baseline entry stores and a later drift comparison compares.

A version bump means the canonical form itself changed shape. Anything that stored a fingerprint under an older version — a committed baseline — is no longer directly comparable and must be regenerated; a drift comparison reads the version first and reports an artifact it cannot interpret rather than comparing across versions blindly.

See also