SQL Parsers
Rules never read raw SQL. They read an analyzer.Statement — a small,
dialect-agnostic struct of facts (Kind, HasWhere, HasLimit,
SelectStar, OffsetValue, …) that a Parser produces. That seam is what
lets the same rules run on a regex-free fallback and on a real grammar.
type Parser interface {
Parse(sql string) (*Statement, error)
}
The default: FallbackParser
Zero dependencies, ships in the core module, never returns an error. It
strips comments and string-literal contents before looking at the
statement, so keywords inside comments or strings and identifiers like
update_at do not cause false positives. CTEs, subqueries and driver
placeholders ($1, ?, :name) are handled well enough for every
built-in rule.
It is best-effort by design. A fact it cannot determine is left false
(or zero), and rules treat that as "not detected", never "proven absent"
— so the failure mode is a missed finding, not a spurious one. Every
Statement it produces has Exact == false.
Opt-in real grammars
For structural certainty, add a dialect parser. Each lives in its own Go module so the grammar dependency never enters your build unless you import it:
go get github.com/KARTIKrocks/sqlguard/parsers/pgparser # PostgreSQL — auxten/postgresql-parser, pure Go
go get github.com/KARTIKrocks/sqlguard/parsers/mysqlparser # MySQL — xwb1989/sqlparser (Vitess-derived), pure Go
import "github.com/KARTIKrocks/sqlguard/parsers/pgparser"
// Runtime middleware or any integration:
sqlguard.Register("sqlguard-pg", "pgx", middleware.WithParser(pgparser.New()))
// Standalone analyzer:
a := analyzer.Default().WithParser(pgparser.New())
middleware.WithParser applies to whichever analyzer is in use, including
one loaded from config, so append(opts, middleware.WithParser(...)) is
enough.
Neither parser uses cgo.
What a real parser changes
| Fact | Fallback | Real parser |
|---|---|---|
Kind (SELECT / INSERT / UPDATE / DELETE / other) | lexical | AST |
HasWhere, HasLimit, HasOrderBy, HasFrom | lexical | AST — correct through CTEs, subqueries, dialect syntax |
SelectStar, SelectDistinct | lexical | AST — COUNT(*) and COUNT(DISTINCT x) never confuse it |
InsertColumnsListed | lexical | AST |
OffsetValue | lexical | AST limit clause |
MaxInListLen (in-list-too-large) | lexical | still lexical — the AST discards the literal list |
ImplicitCommaJoin, CartesianJoin | lexical | still lexical — deliberately text-level |
LeadingWildcardLike, LeadingWildcardTermLen, NonSargablePredicate, AddNotNullNoDefault | lexical | still lexical — they read literal values or DDL text the AST does not carry |
The first group is the false-positive-prone set; those become exact
(Statement.Exact == true). The rest stay best-effort heuristics
regardless of the parser, and each field's doc comment says so. This is a
documented carve-out, not a gap waiting to be closed.
Degradation on parse failure
A real grammar will reject SQL it does not know: dynamic fragments, a
dialect extension, a CTE form the MySQL grammar predates, a placeholder
style it does not model. When that happens the dialect parser returns
the fallback parser's Statement (Exact == false) — never an error.
And if a Parser you write yourself does return an error,
analyzer.Analyze catches it and re-parses with the fallback. Either way,
analysis never breaks the caller's db.Query.
Writing a parser
Implement Parse and fill the Statement fields you can derive; leave the
rest zero. The contract on the runtime path:
- Must not panic. It runs inside every database call.
- Should not error for SQL it merely does not understand — degrade
to a best-effort
Statementinstead.analyzer.NewFallbackParser()is exported so you can delegate to it. - Set
Exactonly for the structural fields you actually computed from an AST.
Then pass it with middleware.WithParser or analyzer.WithParser.