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 — seeGOVERNANCE.md.CanonicalStatementandStatementKindare the internal normalized shape a rule is handed through itsSubject; 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:
| Field | Meaning |
|---|---|
canonicalSql | the normalized SQL text — one statement, formatting erased |
statementKind | what the statement does (see below), or null until classified |
targets | the schema objects it acts on, or null until classified |
transaction | the transaction context the statement runs in |
origin | provenance only — the migration file and the statement's index |
formVersion | the 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, andSeLeCtare 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, andunknown. - 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.usersnever collapses tousers. Treating them as the same object would be an assumption about thesearch_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.