zoobzio December 12, 2025 Edit this page

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:

  1. Validation — Reject invalid identifiers before they enter the system
  2. AST Construction — Build a tree of query components
  3. 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

ProviderImportNotes
PostgreSQLgithub.com/zoobzio/astql/postgresFull feature support
SQLitegithub.com/zoobzio/astql/sqliteRejects unsupported features
MariaDBgithub.com/zoobzio/astql/mariadbON DUPLICATE KEY UPDATE, RETURNING (10.5+)
SQL Servergithub.com/zoobzio/astql/mssqlOUTPUT 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:

FeaturePostgreSQLSQLiteMariaDBSQL Server
Identifier quoting"name""name"`name`[name]
Param placeholder:name:name:name:name
String concatCONCAT()||CONCAT()CONCAT()
String lengthLENGTH()LENGTH()LENGTH()LEN()
Current timeNOW()DATETIME('now')NOW()GETDATE()
Extract yearEXTRACT(YEAR FROM d)STRFTIME('%Y', d)EXTRACT(YEAR FROM d)DATEPART(YEAR, d)
LIMIT/OFFSETLIMIT n OFFSET mLIMIT n OFFSET mLIMIT n OFFSET mOFFSET m ROWS FETCH NEXT n ROWS ONLY
RETURNINGRETURNINGRETURNINGRETURNINGOUTPUT
UpsertON CONFLICTON CONFLICTON DUPLICATE KEY UPDATEUnsupported

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:

LayerWhat It Blocks
Schema validationUnknown tables/fields
Identifier validationSpecial characters, SQL keywords
Alias restrictionsMulti-character aliases
Quoted identifiersReserved words, special chars
Parameterized queriesValue injection
Subquery depth limitsRecursive 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)