Skip to main content

Architecture

UQAL is built around one core principle: the core knows nothing about databases. Every database-specific detail lives in a module. The core provides the language, the grammar, the type system, and the execution model — and explicitly exposes extension points so modules can plug in.

Pipeline

A UQAL script flows through five stages:

Script (text)


┌─────────┐
│ Parser │ Lark earley grammar → parse tree
└─────────┘


┌─────────────┐
│ Transformer │ parse tree → AST (Python dataclasses)
└─────────────┘


┌─────────────┐
│ TypeChecker │ validates table names, field names, types
└─────────────┘


┌─────────┐
│ Planner │ AST → execution plan (ordered steps with dependencies)
└─────────┘


┌──────────┐
│ Executor │ runs each step, calls module.translate() + module.execute()
└──────────┘

Grammar Assembly

The base grammar defines UQAL's core syntax and three extension points — placeholder rules that match nothing by default:

module_table_command: /(?!)/ # new commands on db.table.X()
module_connection_command: /(?!)/ # new commands on db.X()
module_condition: /(?!)/ # new condition expressions
module_param: /(?!)/ # new parameter types

At startup, GrammarBuilder collects grammar extensions from all loaded modules and replaces the placeholders:

parser = GrammarBuilder().build([postgresql_module, neo4j_module])

Each module provides a grammar_extension.lark file and declares which extension points it contributes to via get_capabilities(). Two modules can both contribute to module_condition — their alternatives are merged with |.

Example: Neo4j adds relationship traversal to the condition rule:

# grammar_extension.lark (neo4j)
neo4j_rel_traversal: NAME NAME NAME
| NAME NAME "[" NAME ("," NAME)* "]" NAME
# capabilities.py (neo4j)
grammar_extensions = {
"condition": ["neo4j_rel_traversal"],
}

After GrammarBuilder.build(), the grammar contains:

module_condition: neo4j_rel_traversal

Module Node Registry

When a grammar rule produces an AST node that the core transformer does not know about, the module registers a handler:

# module.py (neo4j) — runs at class-definition time (import time)
def _handle_neo4j_rel_traversal(children: list) -> RelationshipTraversal:
...

register_node_handler("neo4j_rel_traversal", _handle_neo4j_rel_traversal)

The transformer's __default__ method looks up the rule name in the registry and calls the handler if one exists. If no handler is registered, the node passes through as a raw tree node.

When do you need this? Whenever your grammar extension produces a structured AST node (like RelationshipTraversal) rather than a plain string. If your grammar extension maps cleanly to an existing core node type or just produces a string value, you don't need a registry entry.

Execution Model

The planner converts the AST into a flat list of Step objects. Each step has a StepKind, a reference to the relevant AST node, and a dependency list (other steps that must complete first).

StepKindTriggered byExecutor action
COMPUTElet x = arithmetic_exprEvaluates expression locally
DB_READlet x = db.table.get_*(…)module.translate() + module.execute()
DB_WRITEdb.table.insert_row(…) etc.module.translate() + module.execute()
DDLdb.create_view / insert_tablemodule.create_view() / DDL exec
NATIVEdb.sql() / db.cypher() etc.module.execute_native()
OUTPUToutput exprFormats result, writes to output target
CONTROLif / for / whileEvaluates condition, branches

The executor runs steps in dependency order. If a step fails, dependent steps are skipped. Independent steps could theoretically run in parallel (not yet implemented).

Schema Cache

sync_schema loads the database structure into a SchemaStore and caches it locally (default TTL: 24 hours). The TypeChecker reads from the cache to validate table and field names before execution.

If the cache is empty or expired, UQAL triggers a sync automatically on first use. You can also trigger it manually with uqal run "db.sync_schema".

Key Interfaces

The UQALModule abstract class is the contract between the core and a module. Methods are grouped by responsibility:

GroupMethods
Identityget_manifest(), get_capabilities(), get_grammar_extension()
Connectionbuild_connection(), get_connection_schema()
Translationtranslate(), get_type_mapping()
Executionexecute(), execute_native(), validate_native_query()
Schemaget_schema_store(), sync_schema_from_source(), create_view()