Skip to main content
Version: 0.2

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

FactFallbackReal parser
Kind (SELECT / INSERT / UPDATE / DELETE / other)lexicalAST
HasWhere, HasLimit, HasOrderBy, HasFromlexicalAST — correct through CTEs, subqueries, dialect syntax
SelectStar, SelectDistinctlexicalAST — COUNT(*) and COUNT(DISTINCT x) never confuse it
InsertColumnsListedlexicalAST
OffsetValuelexicalAST limit clause
MaxInListLen (in-list-too-large)lexicalstill lexical — the AST discards the literal list
ImplicitCommaJoin, CartesianJoinlexicalstill lexical — deliberately text-level
LeadingWildcardLike, LeadingWildcardTermLen, NonSargablePredicate, AddNotNullNoDefaultlexicalstill 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 Statement instead. analyzer.NewFallbackParser() is exported so you can delegate to it.
  • Set Exact only for the structural fields you actually computed from an AST.

Then pass it with middleware.WithParser or analyzer.WithParser.