Architecture
ASTQL uses a three-stage pipeline: validation, AST construction, and rendering.
Pipeline Overview
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Input │ │ Validation │ │ AST │ │ Output │
│ │ │ │ │ │ │ │
│ Table name │───▶│ DBML schema │───▶│ AST nodes │───▶│ SQL string │
│ Field name │ │ Identifier │ │ constructed │ │ Param list │
│ Param name │ │ SQL keywords │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Each stage has a specific responsibility:
- Validation — Reject invalid identifiers before they enter the system
- AST Construction — Build a tree of query components
- Rendering — Convert the AST to SQL with proper escaping
Validation Layer
Schema Validation
Tables and fields are validated against the DBML schema:
instance, _ := astql.NewFromDBML(project)
instance.T("users") // Checks: does "users" table exist?
instance.F("email") // Checks: does "email" field exist in any table?
The schema acts as an allowlist. If a table or field isn't defined, it can't be used in queries.
Identifier Validation
All identifiers (tables, fields, params, aliases) pass through identifier validation:
func isValidSQLIdentifier(s string) bool {
// Must start with letter or underscore
// Rest must be alphanumeric or underscore
// No SQL keywords or suspicious patterns
}
Blocked patterns include:
- SQL comment markers:
--,/*,*/ - String delimiters:
',",` - Statement terminators:
; - SQL keywords in context:
OR,AND,DROP,DELETE, etc.
Alias Restrictions
Table aliases are restricted to single lowercase letters:
instance.T("users", "u") // Valid
instance.T("users", "ab") // Invalid: must be single letter
This prevents injection through alias names while providing enough aliases for complex joins.
AST Structure
The Abstract Syntax Tree represents a query as nested Go structs:
type AST struct {
Operation Operation // SELECT, INSERT, UPDATE, DELETE, COUNT
Target Table // Main table
Fields []Field // Selected fields
WhereClause ConditionItem // WHERE conditions
Joins []Join // JOIN clauses
GroupBy []Field // GROUP BY fields
Having []ConditionItem // HAVING conditions
Ordering []OrderBy // ORDER BY clauses
Limit *PaginationValue // LIMIT (static or parameterized)
Offset *PaginationValue // OFFSET (static or parameterized)
// ... additional fields for INSERT, UPDATE, etc.
}
Internal Types
Core types are defined in internal/types:
internal/types/
├── ast.go # AST struct and validation
├── table.go # Table type
├── field.go # Field type
├── param.go # Param type
├── condition.go # Condition types (simple, group, comparison)
└── operator.go # Operator constants
These types are internal to prevent direct construction. All access goes through the instance API.
Condition Types
Conditions support multiple patterns:
// Simple condition: field op param
type Condition struct {
Field Field
Operator Operator
Value Param
}
// Condition group: AND/OR of conditions
type ConditionGroup struct {
Logic Logic // AND or OR
Conditions []ConditionItem
}
// Field comparison: field op field
type FieldComparison struct {
LeftField Field
Operator Operator
RightField Field
}
// Subquery condition: field IN (subquery)
type SubqueryCondition struct {
Field *Field
Operator Operator
Subquery Subquery
}
Render Engine
The render engine converts AST nodes to SQL strings.
Identifier Quoting
All identifiers are quoted using PostgreSQL double-quote syntax:
func quoteIdentifier(name string) string {
escaped := strings.ReplaceAll(name, `"`, `""`)
return `"` + escaped + `"`
}
This handles reserved words and special characters safely.
Parameter Placeholders
Parameters are rendered as named placeholders:
func (ctx *renderContext) addParam(param Param) string {
placeholder := ":" + param.Name
// Track for RequiredParams list
return placeholder
}
The output uses :param_name syntax compatible with sqlx.
Parameter Namespacing
Parameters are prefixed to prevent collisions in complex queries.
Nested subqueries (IN, EXISTS, etc.):
// Main query: :user_id
// First subquery (depth 1): :sq1_user_id
// Nested subquery (depth 2): :sq2_user_id
// Nested subquery (depth 3): :sq3_user_id
Maximum subquery depth is 3 levels.
Compound queries (UNION, INTERSECT, EXCEPT):
// Base query: :q0_user_id
// Second query: :q1_user_id
// Third query: :q2_user_id
Each query in a compound operation gets its own q{n}_ prefix. Subqueries within compound queries still use sq{n}_ prefixes relative to their containing query.
Provider Architecture
Rendering is handled by dialect-specific providers that implement the Renderer interface:
type Renderer interface {
Render(ast *types.AST) (*types.QueryResult, error)
RenderCompound(query *types.CompoundQuery) (*types.QueryResult, error)
Capabilities() render.Capabilities
}
The Capabilities() method allows external services to query what features a dialect supports before execution. See the API Reference for details.
Available Providers
| Provider | Import | Notes |
|---|---|---|
| PostgreSQL | github.com/zoobzio/astql/postgres | Full feature support |
| SQLite | github.com/zoobzio/astql/sqlite | Rejects unsupported features |
| MariaDB | github.com/zoobzio/astql/mariadb | ON DUPLICATE KEY UPDATE, RETURNING (10.5+) |
| SQL Server | github.com/zoobzio/astql/mssql | OUTPUT clause, OFFSET/FETCH syntax |
Usage
import "github.com/zoobzio/astql/sqlite"
// Use specific provider
result, err := query.Render(sqlite.New())
Dialect Differences
Providers handle syntax differences automatically:
| Feature | PostgreSQL | SQLite | MariaDB | SQL Server |
|---|---|---|---|---|
| Identifier quoting | "name" | "name" | `name` | [name] |
| Param placeholder | :name | :name | :name | :name |
| String concat | CONCAT() | || | CONCAT() | CONCAT() |
| String length | LENGTH() | LENGTH() | LENGTH() | LEN() |
| Current time | NOW() | DATETIME('now') | NOW() | GETDATE() |
| Extract year | EXTRACT(YEAR FROM d) | STRFTIME('%Y', d) | EXTRACT(YEAR FROM d) | DATEPART(YEAR, d) |
| LIMIT/OFFSET | LIMIT n OFFSET m | LIMIT n OFFSET m | LIMIT n OFFSET m | OFFSET m ROWS FETCH NEXT n ROWS ONLY |
| RETURNING | RETURNING | RETURNING | RETURNING | OUTPUT |
| Upsert | ON CONFLICT | ON CONFLICT | ON DUPLICATE KEY UPDATE | Unsupported |
Each provider rejects unsupported features with clear errors rather than generating invalid SQL.
File Structure
astql/
├── api.go # Public types and package docs
├── builder.go # Query builders (Select, Insert, Update, Delete)
├── expressions.go # Expression helpers (Sum, Case, Window, String, Date, etc.)
├── instance.go # ASTQL instance and validation
├── renderer.go # Renderer interface
├── internal/
│ ├── types/ # Internal AST types
│ │ ├── ast.go
│ │ ├── condition.go
│ │ ├── field.go
│ │ ├── operator.go
│ │ ├── param.go
│ │ └── table.go
│ └── render/ # Shared render utilities
│ └── errors.go
└── pkg/
├── postgres/ # PostgreSQL provider
│ └── postgres.go
├── sqlite/ # SQLite provider
│ └── sqlite.go
├── mariadb/ # MariaDB provider
│ └── mariadb.go
└── mssql/ # SQL Server provider
└── mssql.go
Security Layers
Defense in depth through multiple layers:
| Layer | What It Blocks |
|---|---|
| Schema validation | Unknown tables/fields |
| Identifier validation | Special characters, SQL keywords |
| Alias restrictions | Multi-character aliases |
| Quoted identifiers | Reserved words, special chars |
| Parameterized queries | Value injection |
| Subquery depth limits | Recursive attacks |
Each layer provides independent protection. An attacker would need to bypass all layers to inject SQL.
Extension Points
Custom Expressions
Add field expressions for aggregates, math functions, window functions:
astql.Sum(field) // SUM("field")
astql.Round(field) // ROUND("field")
astql.Round(field, precision) // ROUND("field", :precision)
astql.RowNumber().Over(spec).As("rank") // ROW_NUMBER() OVER (...) AS "rank"
Compound Queries
Set operations combine queries:
query1.Union(query2).OrderBy(field, astql.ASC)
// (SELECT ...) UNION (SELECT ...) ORDER BY "field" ASC
Direct AST Access
For advanced use cases, access the AST directly:
ast, _ := query.Build()
// Inspect or modify ast
result, _ := postgres.New().Render(ast)