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.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 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 asgrouporuseron PostgreSQL andgroupsorrankon MySQL, keeps its quotes, so it still parses where a suggestion writes it. - 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,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, andunknown.rebuild_tableis a maintenance statement that rewrites a whole table, PostgreSQL'sVACUUM FULLandCLUSTER, or MySQL'sOPTIMIZE TABLE.reindexis PostgreSQL'sREINDEX INDEXorREINDEX TABLE, andrefresh_materialized_viewitsREFRESH MATERIALIZED VIEW.unknownis a statement recognized and not modeled, such asSELECT 1, anANALYZEafter a backfill or a plainVACUUM. A statement that is not recognized at all, aREINDEX DATABASEor MySQL'sREPAIR TABLEamong 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 astype,commentordate, is a target wherever the engine accepts it unquoted, and it is written quoted, sotypeand"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_modechange how MySQL reads a statement: underANSI_QUOTESa double quote names an object instead of opening a string, and underNO_BACKSLASH_ESCAPESa backslash inside a string is an ordinary character. When a connection'smodesset either one, orANSI, which includesANSI_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.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. - Follow a MySQL
sql_modethe connection does not declare. The reading comes from the connection'smodes. Without them the session runs under the mode Laravel sets fromstrict, which holds neither setting, or under the server's own whenstrictis unset, and a server that setsANSI_QUOTESorNO_BACKSLASH_ESCAPESfor 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.