# Axel — full documentation > Axel is an ahead-of-time compiler for PostgreSQL. You define your data model in ASL (Axel Schema Language) and write queries in AQL (Axel Query Language); Axel compiles ASL to migration SQL and AQL to parameterized query strings. It is not an ORM: it never wraps a driver, holds a connection, or executes anything on your behalf. This file concatenates every page of the Axel documentation. Pages are separated by a horizontal rule and labelled with their source URL. --- Source: https://struckchure.github.io/axel/why-axel # Why Axel? PostgreSQL is one of the most powerful, battle-tested, and feature-rich database platforms in the world. It provides native support for complex relational schemas, JSON aggregation, custom extensions (`pgvector`, `pg_trgm`, `postgis`), triggers, stored procedures, row-level security (RLS), and sophisticated query optimization. Yet modern application development frequently struggles with the layers placed between our code and PostgreSQL. --- ## The ORM Dilemma Every generation of Object-Relational Mappers (ORMs) and query builders has tried to solve the database interface problem, but they introduce compounding tradeoffs: ### 1. Framework-Coupled ORMs (Django, Rails Active Record) * **The Problem:** Deeply embedded inside specific language and web framework runtimes. * **The Reality:** They abstract the relational model so heavily that writing queries no longer feels like SQL. As queries grow in complexity, debugging the magic, fighting lazy loading, and avoiding accidental N+1 queries requires endless workarounds or dropping into raw SQL strings. ### 2. Convoluted Relationship Models (TypeORM, Sequelize) * **The Problem:** Heavy reliance on entity decorators, bidirectional mapping, and ownership annotations. * **The Reality:** Modeling simple one-to-many or many-to-many relationships turns into mental gymnastics (`@JoinTable`, `@ManyToOne`, `@JoinColumn`, cascading options). A single misconfigured decorator leads to silent data bugs or broken cascade updates at runtime. ### 3. TypeScript Query Builders (Drizzle, Kysely) * **The Problem:** Excellent for type inference and writing SQL expressions in TypeScript, but relational modeling feels estranged. * **The Reality:** Defining relationships requires separate `relations()` helper objects disconnected from the column schema definitions. Querying related data across many-to-many links feels ad-hoc and bolted-on rather than a natural part of the query syntax. ### 4. The Prisma Evolution & Runtime Lock-In (Prisma 6.x vs. Prisma 7) * **The Prisma 6.x Architecture (Rust Engine Binary):** For years, Prisma relied on a compiled Rust query engine binary shipped as a sidecar process. While this enabled community multi-language clients (in Go, Python, and Rust), it introduced substantial IPC overhead, heavy memory footprint, huge deployment bundles, and notorious serverless cold-start latency. * **The Prisma 7 Shift (TypeScript / WASM):** Shipped in late 2025, Prisma 7 removed the Rust engine binary in favor of a pure TypeScript and WebAssembly query compiler. While this resolved binary distribution for Node.js, it **locked Prisma exclusively to the TypeScript/JavaScript ecosystem** — abandoning multi-language compatibility. * **The Core Flaws Persist Across Both:** Whether running via a Rust binary or a WASM module, Prisma remains a complex runtime layer. It still lacks native insert/update conflict resolution (`ON CONFLICT DO UPDATE` / custom upsert logic without raw SQL), cannot declaratively manage PostgreSQL extensions or triggers, and incurs runtime abstraction costs on every query. ### 5. Neglected PostgreSQL Capabilities * **The Problem:** Most ORMs treat databases as generic, lowest-common-denominator storage engines. * **The Reality:** While tools like Drizzle have added basic `pgPolicy` helpers, declarative support across conventional ORMs for PostgreSQL extensions (`pgvector`, `pg_trgm`, `postgis`, `citext`), database triggers (`BEFORE/AFTER INSERT/UPDATE`), custom procedures, partial indexes, and conditional aggregates (`FILTER (WHERE ...)`) is largely missing, forcing teams to maintain out-of-band manual SQL migration scripts. ### 6. Enterprise Demands * Traditional ORMs struggle to deliver the clean, deterministic, and predictable SQL required by high-throughput systems, database administrators, and enterprise compliance standards. --- ## The Axel Solution Axel re-architects database tooling with a fundamental insight: **data modeling and query compilation belong at build time, not inside a heavy runtime library.** ``` ┌────────────────────────┐ ┌────────────────────────┐ │ schema.asl (Schema) │ │ query.aql (Query) │ └───────────┬────────────┘ └───────────┬────────────┘ │ │ ▼ ▼ axel diff axel compile │ │ ▼ ▼ migration.sql parameterized SQL (applied to DB) (executed by your native driver) ``` --- ## Key Advantages ### 1. Compiler, Not a Runtime ORM Axel never wraps a database driver, manages a connection pool, or executes queries on your behalf. * **ASL** compiles into clean, deterministic SQL migrations with checksum history tracking. * **AQL** compiles into parameterized, optimal PostgreSQL query strings (`$1`, `$2`, ...). * You execute the generated SQL using your language's standard, high-performance driver (`pgx`, `node-postgres`, `sqlx`, `bun`, etc.). ### 2. First-Class Relational Modeling Relationships are first-class declarations in ASL, not awkward metadata: ```asl type User { required id: uuid; required email: str; } type Post { required id: uuid; required title: str; required link author: User; # Foreign key column multi link likes: User; # Automatic junction table } ``` Junction tables, foreign key constraints, indexes, and cascades are generated automatically. ### 3. Zero N+1 Queries via JSON Aggregation In traditional ORMs, selecting nested relational graphs either executes $N+1$ separate queries or produces giant Cartesian products across multiple `JOIN`s. Axel compiles nested AQL shapes directly into PostgreSQL lateral subqueries using `json_agg` and `row_to_json`: ```aql select Post { id, title, author: { id, email }, likes: { id, email } } filter .author.id = $author_id; ``` PostgreSQL executes this in a **single scan**, returning fully shaped JSON objects directly to your application with zero runtime assembling overhead. ### 4. Native PostgreSQL as a First-Class Citizen Axel is built specifically for PostgreSQL. It supports advanced PostgreSQL features natively in your schema and query files: * **Extensions:** `extension pgcrypto;`, `extension pg_trgm;`, `extension vector;` * **Row-Level Security (RLS):** `policy user_isolation on Document for all using (.owner = global.current_user);` * **Triggers & Functions:** Declarative triggers that update columns before/after mutations. * **Upsert Conflicts:** `insert User { ... } unless conflict on .email else (update User set { ... });` * **CTE `with` Bindings:** Reusable subquery bindings compiled to `WITH _with_ AS (...)`. * **Aggregates & Grouping:** Native `group by`, `having`, and per-field conditional filters `sum(.amount) filter .status = 'Completed'`. ### 5. Universal Language Support & Generators Because Axel compiles directly to pure SQL, it can be used with **any programming language or runtime**. Axel includes first-party code generators for Go (`pgx`) and TypeScript, and makes it easy to add new languages by writing a generator in Go (or as an external binary plugin): * **Type Safety Everywhere:** Fully typed parameter structs/interfaces and exact row response types generated straight from your AQL query shapes. * **Write Your Own Generator:** You can implement Axel's generator interface in Go to target any language (e.g. Rust, Python, C#, Java, PHP, Elixir). * **Missing Your Language?** If a generator for your language is not yet built-in, please [file an issue on GitHub](https://github.com/struckchure/axel/issues) — we'd love to add official support! ### 6. The AOT Philosophy: Why We Prefer Ahead-of-Time Queries While runtime query builders can be fast, Axel **strongly discourages constructing queries dynamically at runtime** in favor of **Ahead-of-Time (AOT) query compilation**: * **Compile-Time Validation:** Every `.aql` query file is validated against your `.asl` schema during `axel codegen`. Invalid field selections, broken link joins, and type mismatches fail the build before reaching production. * **Zero Runtime CPU Overhead:** There is no string interpolation, AST construction, or driver wrapping in your application process on every request. * **Inspectable, DBA-Friendly SQL:** The generated SQL files can be checked into version control, indexed by DBAs, audited in code reviews, and tested directly against PostgreSQL `EXPLAIN ANALYZE`. * **Exact Type Generation:** Axel generates exact TypeScript types or Go structs for parameters and response payloads directly from your query shapes. --- ## Learn More For an in-depth, head-to-head architectural and code breakdown against Prisma (6.x and 7), Drizzle, TypeORM, Django ORM, and sqlc, read the **[Axel vs. Alternatives](/comparison)** guide. --- Source: https://struckchure.github.io/axel/comparison # Axel vs. Alternatives Choosing how to model and query your PostgreSQL database is one of the most critical architectural decisions in your application. This guide offers an in-depth, technical comparison between **Axel** and the most common database tools across the ecosystem. --- ## Architecture at a Glance | Dimension | Traditional ORMs (Django, TypeORM) | Client Engines (Prisma 6.x / 7) | TS Query Builders (Drizzle) | SQL Codegen (sqlc) | Axel | | :--- | :--- | :--- | :--- | :--- | :--- | | **Execution Model** | Runtime state tracking & reflection | Client-side runtime / WASM compiler | Runtime SQL builder | Ahead-of-time SQL compiler | **Ahead-of-time AQL/ASL compiler** | | **Runtime Footprint** | Heavy (ORM layer in app memory) | Heavy (engine sidecar / WASM runtime) | Zero (lightweight builder) | Zero (pure SQL) | **Zero (pure SQL)** | | **Relational Queries** | Lazy-load / `JOIN` Cartesian explosions | Multiple sequential queries | `relations()` helper | Manual flat joins / custom SQL | **Single-scan `json_agg` lateral subqueries** | | **Migrations** | Declarative or manual | Declarative (`prisma migrate`) | Declarative (`drizzle-kit`) | Manual SQL files | **Declarative AST diffing (`axel diff`)** | | **Language Support** | Single language (Python, TS) | Multi in v6 (Rust), **TS-only in v7** | TS / JS only | Go, Python, TS | **Any language via Go generators** | | **Native Postgres** | Lowest common denominator | Restricted / raw SQL workarounds | Partial (e.g. `pgPolicy`) | Full (raw SQL) | **First-class (extensions, RLS, triggers, upserts)** | --- ## Axel vs. Prisma (Prisma 6.x vs. Prisma 7) Prisma popularised schema-driven data modeling, but its runtime architecture and operational trade-offs have evolved through distinct eras. ### 1. Architecture & Multi-Language Support * **Prisma 6.x (Rust Engine Binary):** Shipped a compiled native Rust binary as a sidecar process. While this allowed community clients in Go, Python, and Rust to communicate with the engine via IPC, it introduced high memory overhead, massive Docker/deployment bundles, and severe cold starts in serverless environments. * **Prisma 7 (TypeScript / WASM Runtime):** Deprecated and completely removed the Rust query engine in favor of a TypeScript and WebAssembly query compiler. While this resolved native binary bundling issues for Node.js, it **locked Prisma exclusively into the JavaScript/TypeScript ecosystem**, abandoning native multi-language support. * **Axel:** Has **zero runtime process, zero WASM overhead, and zero driver wrapping**. Axel compiles at build time into pure SQL. Because Axel's code generators are written in Go (or as external plugins), it natively supports Go (`pgx`), TypeScript, and any other programming language. ### 2. Upsert and Conflict Handling * **Prisma:** Prisma's `upsert` operation requires a unique index and only allows basic scalar updates. It cannot handle multi-column expression targets, conditional conflict updates, or complex `DO UPDATE SET` logic without writing raw SQL strings (`$queryRaw`). * **Axel:** Supports PostgreSQL's full `ON CONFLICT` semantics natively through `unless conflict`: ```aql insert User { email := $email, name := $name, login_count := 1 } unless conflict on .email else ( update User set { name := $name, login_count := .login_count + 1 } ); ``` ```sql INSERT INTO "user" (email, name, login_count) VALUES ($1::TEXT, $2::TEXT, 1) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, login_count = "user".login_count + 1; ``` ### 3. Nested Relational Queries * **Prisma:** When fetching nested relations with `include`, Prisma typically executes multiple sequential SQL queries and stitches the objects together in memory in the Node.js/WASM runtime. * **Axel:** Compiles nested relational shapes into a single PostgreSQL scan using `json_agg` and `row_to_json`. PostgreSQL does the aggregation directly on the database engine, avoiding network chattiness and runtime CPU overhead. --- ## Axel vs. Drizzle & Kysely Drizzle and Kysely are modern, TypeScript-first SQL builders that emphasize close-to-SQL syntax and zero runtime overhead. ### 1. Relational Modeling * **Drizzle:** Tables are defined with `pgTable()`, but relational queries require a completely separate `relations()` helper file where links and foreign keys must be duplicated. Querying related data across many-to-many junction tables requires manual configuration of junction relations. * **Axel:** Single links (`link author: User`) and many-to-many relationships (`multi link members: User`) are declared directly on the type in ASL. Axel automatically creates and manages foreign keys and junction tables. ```asl type Post { required id: uuid; required title: str; required link author: User; # FK column multi link tags: Tag; # Junction table created automatically } ``` ```typescript // Schema file export const posts = pgTable('posts', { id: uuid('id').primaryKey(), title: text('title').notNull(), authorId: uuid('author_id').references(() => users.id).notNull(), }); // Separate relations file export const postsRelations = relations(posts, ({ one, many }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id] }), postTags: many(postTags), })); ``` ### 2. Query Syntax & Composability * **Drizzle:** Relational queries use the `db.query.posts.findMany({ with: { author: true } })` API, while complex filters, aggregates, and CTEs use the separate SQL query builder API (`db.select().from(...)`). Combining the two often requires dropping into raw SQL template strings (`sql`...``). * **Axel:** AQL provides a single, unified query language that combines nested shape projections, top-level CTE `with` blocks, boolean filter expressions, conditional aggregates, `group by`, and `having` seamlessly. ### 3. Language Portability * **Drizzle / Kysely:** Bound exclusively to TypeScript and JavaScript runtimes. * **Axel:** Language-agnostic. Use the same `.asl` schemas and `.aql` queries across your Go backend, TypeScript web app, and microservices. ### 4. AOT Queries vs. Runtime Query Builders * **Query Builders (Drizzle / Kysely):** Build SQL strings dynamically in application memory at request time. Errors in dynamic queries or relational joins are only discovered when that code branch runs at runtime. * **Axel:** Strongly favors **Ahead-of-Time (AOT) queries**. Every `.aql` query is compiled and verified against your schema at build time via `axel codegen`, producing verified SQL and exact type-safe models with zero runtime CPU query-building cost. --- ## Axel vs. TypeORM & Sequelize TypeORM and Sequelize represent traditional object-oriented ORM patterns in Node.js. ### 1. Mental Model & Decorator Fragility * **TypeORM:** Relies on heavy TypeScript experimental decorators (`@Entity`, `@ManyToOne`, `@ManyToMany`, `@JoinTable`, `@Column`). A small error in decorator configuration can cause silent data corruption, failed cascades, or unexpected query mutations. * **Axel:** Pure, declarative schemas in `.asl` with clear AST semantics and compile-time validation. ### 2. State Tracking and Entity Mutation * **TypeORM:** Tracks entity state in memory with an `EntityManager`. Calling `repository.save(entity)` can trigger unintended SQL queries depending on which fields were modified in JavaScript memory. * **Axel:** Purely stateless. Axel queries compile to exact, parameterized SQL statements with zero runtime tracking or surprise queries. --- ## Axel vs. Django ORM & Rails Active Record Django ORM and Rails Active Record are foundational framework ORMs that prioritize developer velocity within monolithic web frameworks. ### 1. Framework Coupling * **Django / Rails:** Deeply tied to Python / Ruby and their respective web framework lifecycles. Sharing models with external services or non-Python/Ruby applications is nearly impossible. * **Axel:** Independent CLI and compiler. Use Axel in any framework, microservice, or language stack. ### 2. "SQL-Like" vs. Leaky Abstractions * **Django:** Querysets abstract SQL so heavily (e.g. `User.objects.filter(post__tags__name__icontains='tech')`) that developers lose visibility into the underlying SQL execution plan. Resolving performance bottlenecks requires learning complex ORM-specific idioms (`select_related`, `prefetch_related`, `F()` expressions, `annotate()`). * **Axel:** AQL is designed as a direct, intuitive evolution of SQL. Every AQL clause maps cleanly and predictably to PostgreSQL execution, with zero hidden queries. --- ## Axel vs. sqlc & Raw SQL `sqlc` compiles raw SQL queries into type-safe Go/TypeScript/Python code. ### 1. Schema & Migration Management * **sqlc:** Expects you to write raw PostgreSQL DDL (`CREATE TABLE ...`, `ALTER TABLE ...`) and manually create and manage every migration file. It does not diff schemas or generate migrations. * **Axel:** Axel provides full schema diffing and automatic migration generation (`axel diff`), tracking migration execution and checksums in PostgreSQL (`_axel_migrations`). ### 2. Nested Relational Projections * **sqlc:** Writing nested relational queries requires manually authoring complex `json_build_object` and `json_agg` lateral joins in raw SQL, which is verbose, error-prone, and difficult to maintain. * **Axel:** Nested shapes are declared with intuitive `{ id, author: { id, email }, likes: { id } }` syntax. Axel automatically emits the optimal `json_agg` lateral subqueries for you. --- ## Summary Axel is built for teams that love PostgreSQL and want the **type safety and developer ergonomics of a modern schema tool** without sacrificing **SQL transparency, performance, or language portability**. | Use Case | Best Choice | | :--- | :--- | | You want single-scan nested relational queries with zero N+1 | **Axel** | | You want native PostgreSQL extensions, triggers, RLS, and upserts | **Axel** | | You want to share schemas and queries across Go, TypeScript, and other languages | **Axel** | | You want an interactive database studio built into your workflow | **Axel** | | You want a quick TypeScript-only SQL builder with no custom DSL | **Drizzle** | | You want to write 100% raw SQL files without schema diffing | **sqlc** | --- Source: https://struckchure.github.io/axel/installation # Installation ## macOS / Linux Run the install script: ```sh curl -fsSL https://raw.githubusercontent.com/struckchure/axel/main/scripts/install.sh | bash ``` The script places the `axel` binary in `~/.local/bin` (Linux) or `/usr/local/bin` (macOS) and adds it to your `PATH`. Verify the installation: ```sh axel version ``` ## Windows Open PowerShell and run: ```powershell irm https://raw.githubusercontent.com/struckchure/axel/main/scripts/install.ps1 | iex ``` The script downloads the binary to `%LOCALAPPDATA%\axel\` and adds it to your user PATH. Restart your terminal, then verify: ```powershell axel version ``` ## Build from source Requires Go 1.21+. ```sh git clone https://github.com/struckchure/axel.git cd axel go build -o axel ./cmd ``` Move the binary somewhere on your `PATH`: ```sh mv axel /usr/local/bin/axel # macOS / Linux ``` ## Configuration Create an `axel.yaml` in your project directory: ```yaml database-url: postgres://user:pass@localhost:5432/mydb schema-path: ./schema.asl migrations-dir: ./migrations ``` Run commands directly in your project folder: ```sh axel validate axel up ``` If your project is in a different directory, pass `--dir` (or `-d`): ```sh axel -d ./myproject validate ``` Discovery order inside project directory: 1. `axel.yaml` — loaded as the full config if found 2. `schema.asl` — used as the schema if no `axel.yaml` 3. `default.asl` — fallback schema filename You can also set the database URL via an environment variable: ```sh export DATABASE_URL=postgres://user:pass@localhost:5432/mydb axel up ``` --- Source: https://struckchure.github.io/axel/tutorial # Tutorial: your first Axel project This walkthrough takes you from an empty folder to a running app backed by PostgreSQL — using Axel for the schema, the migrations, the queries, and the generated client code. By the end you'll have: - a schema written in **ASL** and applied to a real database as a migration, - two queries written in **AQL**, and - typed **TypeScript** and **Go** code generated from them, called from a small program. We'll build a tiny blog: `User`s who write `Post`s. :::tip[Prefer to read the finished result first?] Every file in this tutorial mirrors the runnable [`examples/basic`](https://github.com/struckchure/axel/tree/main/examples/basic) project in the repo. Clone it if you'd rather poke at the end state. ::: --- ## Prerequisites - **The `axel` CLI** — see [Installation](./installation). Verify with `axel version`. - **A PostgreSQL database.** Any Postgres works; this tutorial uses a throwaway one in Docker (below), so you don't touch a real database. - One of a **Bun** or **Go** toolchain, for the final "run it" step. There is no `axel init` — an Axel project is just a config file, a schema file, and a folder of queries that you create yourself. That's what we do in Step 1. --- ## Step 1 — Create the project Make a folder and lay out these files: ``` blog/ axel.yaml # project config schema.asl # your schema (ASL) queries/ # your queries (AQL) — we add files here later ``` `axel.yaml` tells Axel where your schema and migrations live, and how to reach the database: ```yaml [axel.yaml] database-url: postgres://user:password@localhost:5432/db?sslmode=disable schema-path: ./schema.asl migrations-dir: ./migrations ``` When `axel.yaml` is in the current directory, Axel discovers it automatically. You can run `axel ` directly from your project root. (You only need `-d ` or `--dir ` if your config lives in a different folder). :::tip[Keep the URL out of the file] To avoid hardcoding connection credentials, reference your environment variable in `axel.yaml`: ```yaml database-url: "$env.DATABASE_URL" ``` And export `DATABASE_URL=postgres://…` in your shell or `.env` file. ::: --- ## Step 2 — Write the schema (ASL) Put this in `schema.asl`. It defines an abstract `Base` type (shared columns), then `User`, `Post`, and `Comment` that extend it: ```asl [schema.asl] use extension 'pgcrypto'; abstract type Base { required id: uuid { default := gen_uuid(); constraint exclusive; constraint pk; }; required created_at: datetime { default := datetime_current(); }; required updated_at: datetime { default := datetime_current(); rewrite update := datetime_current(); }; } type User extends Base { required email: str { constraint exclusive; constraint min_length(10); constraint max_length(100); }; name: str { default := 'n/a'; }; required age: int32; required health: int32; active: bool { default := true }; } type Post extends Base { required title: str; required content: str; required link author: User; # single link → FK column "author" multi link likes: User; # multi link → junction table "post_likes" } type Comment extends Base { required content: str; required link post: Post; required link author: User; } ``` A few things worth noticing, each covered in the [ASL reference](/asl/): - **`use extension 'pgcrypto';`** enables the `pgcrypto` PostgreSQL extension, which provides cryptographic functions like `gen_random_uuid()` (or `gen_uuid()`). - **`abstract type Base`** is never a table on its own; its fields are inlined into every type that `extends` it. - **`constraint`s** (`exclusive`, `pk`, `min_length`) compile to SQL `UNIQUE` / `PRIMARY KEY` / `CHECK` clauses. - **`required link author: User`** is a one-to-many foreign key; **`multi link likes: User`** is a many-to-many that Axel backs with a junction table. --- ## Step 3 — Validate it Before touching the database, check the schema parses and type-checks: ```sh axel validate ``` ``` schema "schema.asl" is valid (4 types) ``` `validate` never connects to a database, so it's the fastest feedback loop while you're editing the schema. --- ## Step 4 — Start PostgreSQL Any Postgres will do. To spin up a disposable one matching the URL in `axel.yaml`, drop this `docker-compose.yaml` next to it and start it: ```yaml [docker-compose.yaml] services: postgres: image: postgres:17-alpine environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=db ports: - 5432:5432 ``` ```sh docker compose up -d ``` --- ## Step 5 — Generate and apply the first migration `axel diff` diffs your schema against the last migration and writes a new one. On a fresh project that diff is "create everything": ```sh axel diff -n "initial schema" ``` This writes a versioned migration folder: ``` migrations/ 0001/ up.sql # forward migration down.sql # rollback metadata.json # version, checksum, schema snapshot ``` Open `migrations/0001/up.sql` to see the SQL Axel produced — abbreviated here: ```sql CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE "user" ( "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "email" TEXT NOT NULL UNIQUE, "name" TEXT DEFAULT 'n/a', "age" INTEGER NOT NULL, "health" INTEGER NOT NULL, "active" BOOLEAN DEFAULT true, "id" UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE PRIMARY KEY ); CREATE TABLE "post" ( "content" TEXT NOT NULL, "id" UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE PRIMARY KEY, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "title" TEXT NOT NULL, "author" UUID NOT NULL, FOREIGN KEY ("author") REFERENCES "user"("id") ON DELETE CASCADE ); -- plus "post_likes" (junction for multi link likes) and "comment" ``` Apply it: ```sh axel up ``` Axel records applied migrations in a `_axel_migrations` table it creates on first run. Check the state any time with: ```sh axel status ``` ``` 0001 applied ``` :::tip[Rolling back] `axel down 1` reverts the last migration using its `down.sql`. Edit the schema and re-run `diff` to produce `0002`, and so on. ::: --- ## Step 6 — Write queries (AQL) Queries live in `.aql` files. Create two under `queries/`. An **insert** that takes one parameter (`queries/create_user.aql`): ```aql [queries/create_user.aql] insert User { email := $email, age := 100, health := 100 }; ``` A **nested read** — every user with their posts, pulled in a single query (`queries/list_users_with_post.aql`): ```aql [queries/list_users_with_post.aql] multi select User { id, email, posts := (select Post { id, title } filter .author.id = User.id) } ``` The `posts := (…)` shape is the important bit: Axel compiles it to a `json_agg` sub-select, so related rows come back as a nested JSON array with **no N+1 queries**. See the [AQL reference](/aql/) for filters, ordering, and the other statement types. --- ## Step 7 — See the compiled SQL (optional) Before generating code, you can inspect exactly what a query compiles to. `axel compile` needs no database: ```sh axel compile --file queries/list_users_with_post.aql ``` ```sql SELECT u.id AS id, u.email AS email, (SELECT json_agg(row_to_json(p_posts_sub)) FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts FROM "user" u; ``` Code generation (next) runs this compiler for you, so this step is purely for seeing under the hood. --- ## Step 8 — Generate typed code Point `codegen` at the project and pick a generator. It compiles every `.aql` file and emits a typed client: ```sh axel codegen -g ts -o ./gen ``` ```sh axel codegen -g go -o ./gen --option package=generated ``` You get one models file, one file per query, and a `runner` that ties them together: ``` gen/ models.{ts,go} # one interface/struct per concrete type create_user.{ts,go} # typed params + row + function list_users_with_post.{ts,go} runner.{ts,go} # Runner with typed query methods ``` `models` is one type per concrete ASL type (abstract `Base` is inlined, not emitted): ```ts // Code generated by axel codegen --generator ts. DO NOT EDIT. export interface User { active: boolean | null; age: number; createdAt: Date; email: string; health: number; id: string; name: string | null; updatedAt: Date; } // … Post, Comment ``` ```go // Code generated by axel codegen --generator go. DO NOT EDIT. package generated type User struct { Active *bool `json:"active" db:"active"` Age int32 `json:"age" db:"age"` CreatedAt time.Time `json:"created_at" db:"created_at"` Email string `json:"email" db:"email"` Health int32 `json:"health" db:"health"` ID string `json:"id" db:"id"` Name *string `json:"name" db:"name"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // … Post, Comment ``` Each query becomes a fully-typed function. Here's the nested read — note how the `posts` shape produced a nested `Posts` type: ```ts // Code generated by axel codegen --generator ts. DO NOT EDIT. export interface ListUsersWithPostRow { id: string; email: string; posts: ListUsersWithPostRowPosts[]; } export interface ListUsersWithPostRowPosts { id: string; title: string; } export async function listUsersWithPost(db: DB): Promise { const query = `SELECT u.id AS id, u.email AS email, (SELECT json_agg(row_to_json(p_posts_sub)) FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts FROM "user" u;`; return db.unsafe(query); } ``` ```go // Code generated by axel codegen --generator go. DO NOT EDIT. package generated "context" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type ListUsersWithPostRow struct { ID string `json:"id" db:"id"` Email string `json:"email" db:"email"` Posts []ListUsersWithPostRowPosts `json:"posts" db:"posts"` } type ListUsersWithPostRowPosts struct { ID string `json:"id" db:"id"` Title string `json:"title" db:"title"` } func ListUsersWithPost(ctx context.Context, db *pgxpool.Pool) ([]ListUsersWithPostRow, error) { const query = `SELECT u.id AS id, u.email AS email, (SELECT json_agg(row_to_json(p_posts_sub)) FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts FROM "user" u;` rows, err := db.Query(ctx, query) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[ListUsersWithPostRow]) } ``` The full generator options — the TypeScript `client` (`bun` vs `pg`), the Go `package`, enum handling, and the `@name` / `@request` / `@response` directives — are in the [Code Generation guide](./codegen). --- ## Step 9 — Use it in your app The generated `Runner` exposes every query as a typed method under `query` (TypeScript) / `Query` (Go). Wire it to a database connection and call them. ```ts const sql = new SQL({ url: "postgres://user:password@localhost:5432/db?sslmode=disable", }); const runner = new Runner(sql); // INSERT — typed params in, typed row out const alice = await runner.query.createUser({ email: "alice@example.com" }); console.log("created", alice?.id); // Nested read — users with their posts, in a single round-trip const users = await runner.query.listUsersWithPost(); for (const u of users) { console.log(u.email, u.posts.map((p) => p.title)); } ``` ```go package main "context" "fmt" "log" "github.com/jackc/pgx/v5/pgxpool" generated "yourmodule/gen" ) func main() { ctx := context.Background() db, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/db?sslmode=disable") if err != nil { log.Fatalln(err) } defer db.Close() runner := generated.NewRunner(db) // INSERT — typed params in, typed row out alice, err := runner.Query.CreateUser(ctx, generated.CreateUserParams{Email: "alice@example.com"}) if err != nil { log.Fatalln(err) } fmt.Println("created", alice.ID) // Nested read — users with their posts, in a single round-trip users, err := runner.Query.ListUsersWithPost(ctx) if err != nil { log.Fatalln(err) } for _, u := range users { fmt.Println(u.Email, len(u.Posts)) } } ``` Run it: ```sh bun run app.ts ``` ```sh go run . ``` :::tip[Bun is the default TS client] The generated TypeScript targets Bun's `SQL` class out of the box. For [node-postgres](https://node-postgres.com) instead, regenerate with `--option client=pg` and pass a `Pool` to the `Runner`. ::: --- ## Recap You went from an empty folder to a working, type-safe data layer: 1. **`axel.yaml` + `schema.asl`** — declared the project and its types. 2. **`axel validate`** — checked the schema with no database. 3. **`axel diff` + `axel up`** — turned the schema into migration SQL and applied it. 4. **`.aql` files** — wrote queries, including a nested shape that avoids N+1. 5. **`axel codegen`** — got typed TypeScript and Go clients. 6. Called the generated `Runner` from a real program. The whole loop — edit schema → `diff` → `up`, edit queries → `codegen` — is what you repeat as the project grows. ## Next steps - [Schema Language (ASL)](/asl/) — enums, computed fields, indexes, all constraints. - [Query Language (AQL)](/aql/) — filters, ordering, `insert`/`update`/`delete`, operators. - [Code Generation](./codegen) — generator options, directives, and writing your own generator. - [CLI Reference](./cli) — every command and flag. - [Editor setup](./editors) — syntax highlighting and the language server. --- Source: https://struckchure.github.io/axel/editors # Editor setup Axel ships editor extensions for **Zed** and **VS Code**. Each provides syntax highlighting plus a language server — live diagnostics, hover, go-to-definition, and completion — for `.asl` schemas and `.aql` queries. ## Prerequisites The language server *is* the `axel` CLI (`axel lsp`), so both editors need `axel` installed and on your `PATH`. Install it first (see [Installation](/installation)) and verify: ```sh axel version ``` If `axel` isn't found, syntax highlighting still works, but the language-server features won't start (the extension shows a "not found" notice). Both extensions live in the Axel repo under `tools/`, so clone it: ```sh git clone https://github.com/struckchure/axel.git cd axel ``` ## Zed Zed installs the extension from a local directory as a **dev extension** and compiles it on install: the tree-sitter grammars and the Rust language-server shim. You need a [Rust toolchain](https://rustup.rs) (Zed adds the wasm target itself). 1. Open the command palette (`cmd-shift-p`) and run **`zed: install dev extension`**. 2. Select the `tools/zed` directory in your Axel checkout. 3. Open a `.asl` or `.aql` file — highlighting and LSP features activate automatically. To use a specific `axel` binary instead of the one on `PATH`, add to your Zed `settings.json`: ```json { "lsp": { "axel": { "binary": { "path": "/absolute/path/to/axel" } } } } ``` More detail: `tools/zed/README.md`. ## VS Code Build a `.vsix` and install it with the `code` CLI. Requires [Bun](https://bun.sh). ```sh cd tools/vscode bun install bun run package # produces axel-.vsix code --install-extension axel-*.vsix ``` Reload VS Code, then open a `.asl` or `.aql` file. To use a specific `axel` binary, set it in **Settings** (`axel.path`): ```json { "axel.path": "/absolute/path/to/axel" } ``` **Developing the extension:** open the `tools/vscode` folder in VS Code and press `F5` to launch an Extension Development Host with the extension loaded; after editing the source, re-run `bun run package` and reinstall. Use the **Axel: Restart Language Server** command to reconnect after installing or updating `axel`. More detail: `tools/vscode/README.md`. ## What you get - **Syntax highlighting** for `.asl` and `.aql`. - **Diagnostics** — live parse/resolve errors for schemas, and parse/compile errors for queries. - **Hover, go-to-definition, and completion**, powered by `axel lsp` (including full IntelliSense and documentation for AQL built-in directives `@name`, `@request`, `@response`, `@rel_load_strategy`, and schema directives). - **Configuration autocomplete & validation** in `axel.yaml` via YAML language server schema integration (`# yaml-language-server: $schema=...`). Query files are resolved against your schema via `axel.yaml` (`schema-path`) in the workspace root, so completion and cross-file diagnostics know your types. When `schema-path` is a directory or a glob, the language server merges the whole [split schema](/asl/splitting) before resolving it: a type declared in one file is known in every other, and a problem is reported against the file that owns it. --- Source: https://struckchure.github.io/axel/ai # AI tooling Two ways to give a coding agent an accurate picture of Axel, instead of letting it guess from a half-remembered ORM. ## llms.txt The documentation site publishes machine-readable copies of itself, regenerated on every build so they cannot drift: | File | Contents | |---|---| | [`/llms.txt`](https://struckchure.github.io/axel/llms.txt) | An index of every page — title, URL and one-line description, grouped by section | | [`/llms-full.txt`](https://struckchure.github.io/axel/llms-full.txt) | The entire documentation as one plain-text file | Point any tool that accepts a docs URL at them: ```sh curl -s https://struckchure.github.io/axel/llms-full.txt -o axel-docs.txt ``` Both follow the [llms.txt convention](https://llmstxt.org). ## Agent guide The repo ships a drop-in guide that teaches a coding agent the ASL and AQL languages, the `axel` workflow, and the errors each stage produces. Install it with [`npx skills`](https://github.com/vercel-labs/skills): ```sh npx skills add struckchure/axel ``` That works for Claude Code, Codex, Cursor, OpenCode, Copilot and 70-odd other agents — it detects what you have and writes the guide where that tool looks for it. To pin the agent and install globally instead of into the current project: ```sh npx skills add struckchure/axel --skill axel -a claude-code -g -y ``` The source lives in [`tools/agent`](https://github.com/struckchure/axel/tree/main/tools/agent) and is plain Markdown, so you can also just copy it wherever your tool reads instructions — its [README](https://github.com/struckchure/axel/tree/main/tools/agent) lists the destinations. What it corrects, in practice: - Axel **compiles**; it never executes a query for you. Agents trained on ORMs reliably invent a runtime that does not exist. - A plain `select` returns **one** row. `multi select` returns a set. - There are **no reverse links** — `Post` having `link author: User` does not give `User` a `.posts`. - A single `link author: User` produces a column named **`author`**, not `author_id`. - `multi` on a **scalar** is an array column; `multi link` is a junction table. Membership against the first compiles to `= ANY(...)`, against the second to an `EXISTS`. - A `multi` parameter binds **one array**, not one value per element. - Migrations are generated by `axel diff`, never hand-edited — with one exception: filling in the backfill seam Axel leaves when a required column is added to a populated table. - **Always format** `.asl` and `.aql` files with `axel fmt -w .` after writing or modifying them. It also carries full ASL and AQL grammar references, and every example in them was compiled with `axel` before being written down. ## Working with an assistant Whatever tool you use, the loop that keeps it honest is the same one you would use yourself: ```sh axel fmt -w . # format .asl / .aql files in place axel validate # does the schema resolve? axel compile --aql 'select User { id }' # what SQL does this shape produce? axel diff -n "wip" && cat migrations/*/up.sql # what DDL does this change produce? ``` All four are fast, and the first three need no database. Ask for the output rather than the explanation. --- Source: https://struckchure.github.io/axel/studio # Studio Axel Studio is a browser-based database viewer and editor in the style of Neon / Prisma Studio. It reads your schema as ASL types, browses table data through a typed grid, and edits rows — inserts, updates, deletes, and links — by applying [AQL](/aql/) under the hood. It ships with the `axel` binary (assets are embedded, so it runs from any directory). ```sh axel studio # → Axel Studio listening on http://localhost:4530 ``` ## Connection & schema Studio needs a PostgreSQL connection and, for its schema-aware features, an `.asl` file. Both are resolved the same way the other commands resolve them: | Value | Resolution order | |---|---| | Database URL | `--url` → `axel.yaml` `database-url` → `AXEL_DATABASE_URL` / `DATABASE_URL` | | Schema path | `--schema-path` → `axel.yaml` `schema-path` → `AXEL_SCHEMA_PATH` → auto-discovered `default.asl` | ```sh # Explicit axel studio --url 'postgres://user:pass@localhost:5432/app?sslmode=disable' \ --schema-path ./schema.asl # Or from a project's axel.yaml axel studio -d ./my-project ``` | Flag | Default | Description | |---|---|---| | `--addr` | `:4530` | Listen address | | `--url` / `-u` | — | PostgreSQL connection URL | | `--schema-path` | — | Path to an `.asl` schema | | `--dir` / `-d` | — | Project directory (auto-discovers `axel.yaml`) | ### What lights up when Studio degrades gracefully so the UI always renders: - **Live database + schema** — the full experience: the sidebar lists your ASL types, data reads run as AQL, the AQL console executes, and editing is enabled. - **Live database, no schema** — falls back to PostgreSQL introspection: tables come from `information_schema` and the AQL console is disabled (raw SQL still works). - **No database reachable** — serves representative **sample data** so you can see the interface; the consoles show connect-a-database guidance. ## The workspace A schema sidebar (with a live filter) on the left; the selected table opens with four tabs: - **Data** — a typed grid with sticky headers, click-to-sort columns, and pagination. NULLs, booleans, timestamps, JSON, and links are rendered distinctly; primary keys and foreign keys are badged. - **Structure** — columns with their ASL and SQL types, nullability, defaults, and keys (PK / FK / multi-link). - **AQL** — a console that runs read & write AQL against the connected database and shows the compiled SQL. Without a live database it compiles-only (validates and lowers to SQL without executing). - **SQL** — a raw SQL console for read-only queries. ## Editing data On a live, schema-backed table the grid is editable — changes stage locally and apply together, so you review before anything hits the database: - **Edit** — double-click a scalar cell and type. Edited cells are highlighted. - **Insert** — *Insert row* adds a draft row to fill in. - **Delete** — hover a row's index and click ×. - **Save / Discard** — a review bar shows the pending count (e.g. *2 updates · 1 delete*). **Save** applies everything as AQL `insert` / `update` / `delete`; **Discard** reloads the page. ### Links Relationships are editable too: - **Single links** render as a picker — a dropdown of candidate rows from the target type (labeled by name/title/email). Saving resolves the choice with `link := (select Target filter .id = $id)`. - **Multi links** render as **tag chips** — add from a dropdown, remove with ×. Saving reconciles the selection against the link's junction table. ## Row-level security Studio connects with the URL you give it, so it runs as that role. If the role is subject to [RLS policies](/asl/policies), the data grid and AQL reads honor them — e.g. a `hide_expired` policy keeps TTL-expired rows out of the grid. Connect with a non-owner application role to see policies applied (the table owner bypasses RLS). This makes Studio a faithful view of what your application actually sees. ## Development From the repo, `task dev` runs templ, Tailwind, and the server together with hot reload — use it instead of a bare `go run` so styles stay fresh. --- Source: https://struckchure.github.io/axel/codegen # Code Generation Axel can generate type-safe code from your ASL schema and compiled AQL queries. Two generators are built in — Go and TypeScript — and you can write your own in any language. :::tip[Why Axel Recommends AOT Queries] Axel embraces **Ahead-of-Time (AOT) queries** rather than dynamic runtime query builders. By storing queries in `.aql` files and compiling them during your build step, you guarantee that all queries are syntactically valid against your latest database schema, enjoy 0 runtime string/AST assembly overhead, and get exact generated types for parameters and responses. ::: --- ## Quick start ```sh # Go axel -d ./myproject codegen -g go -o ./gen # TypeScript axel -d ./myproject codegen -g ts -o ./gen ``` Axel auto-discovers all `*.aql` files under the project directory and compiles them together with the schema. --- ## `axel codegen` ``` axel codegen [flags] [query-files...] ``` | Flag | Short | Default | Description | |-----------------|-------|---------|-------------| | `--generator` | `-g` | | Built-in generator name (`go` or `ts`) | | `--plugin` | `-p` | | Path to an external generator binary | | `--out-dir` | `-o` | `.` | Directory to write generated files into | | `--query` | `-q` | | AQL file or glob pattern (repeatable) | | `--schema-path` | | | Schema file (default: from config or `axel/schema.asl`) | | `--option` | | | `key=value` passed to the generator (repeatable) | `--generator` and `--plugin` are mutually exclusive. ### Configuration in `axel.yaml` Codegen settings can be configured directly in your `axel.yaml` configuration file under the `codegen` key. Flags passed via the CLI always take precedence and override settings in the config file. ```yaml # axel.yaml schema-path: ./axel/schema.asl rel-load-strategy: query # query | join codegen: generator: go # go | ts out-dir: ./db/generated queries: - ./queries/*.aql options: package: generated ``` With this configured, running `axel codegen` will automatically use your specified generator, output directory, query files, and options without needing extra CLI arguments. ### Query file discovery Query files are resolved in this priority order: 1. `-q` / `--query` patterns passed via CLI — Axel expands these (supports `**/*.aql`) 2. Positional arguments — shell-expanded paths 3. `codegen.queries` in `axel.yaml` 4. Auto-discovery — all `*.aql` files under `--dir` when nothing else is given ```sh # Explicit list axel codegen -g go -o ./gen -q 'queries/**/*.aql' # Auto-discover from project dir axel -d ./myproject codegen -g go -o ./gen # Mix: all queries plus one extra axel codegen -g go -o ./gen -q 'queries/*.aql' extra.aql ``` ### Directives Directives are `@ ` declarations placed before a query. They carry codegen metadata and are parsed as part of the AQL AST (not comments). Recognized directives: | Directive | Effect | |-----------|--------| | `@name ` | Sets the query/function name (overrides the filename-derived default) | | `@request ` | Names the params struct/interface (default: `Params`) | | `@response ` | Names the row struct/interface (default: `Row`) | | `@rel_load_strategy ` | Overrides the relation loading strategy (`join` or `query`) for this query | ```aql @name CreateUser @request CreateUserInput @response User @rel_load_strategy join insert User { email := $email, name := $name }; ``` > `@name` replaces the older `# @name` comment annotation, which is no longer recognized. Directive-named types are **shared and deduplicated** across query files: a name used by more than one query (or one matching an existing schema type) is emitted **once** and reused. If two queries claim the same name but describe **different fields**, codegen **aborts** with an error naming both sources — so a shared type can never silently diverge. All parsed directives are also exposed to external generators as the `directives` object on each query descriptor. --- ## TypeScript generator (`-g ts`) ### Generated files | File | Contents | |------|----------| | `models.ts` | One `interface` per concrete ASL type; one `type` alias per enum | | `.ts` | Typed async function per AQL query with params and row interfaces | | `runner.ts` | `Runner` class, `Queries` class, builder infrastructure, embedded schema | ### Type mapping | AQL type | TypeScript type | Nullable TypeScript type | |---------------|------------------|--------------------------| | `str` | `string` | `string \| null` | | `int16/32/64` | `number` | `number \| null` | | `float32/64` | `number` | `number \| null` | | `bool` | `boolean` | `boolean \| null` | | `uuid` | `string` | `string \| null` | | `datetime` | `Date` | `Date \| null` | | `json` | `unknown` | `unknown` | An **enum**-backed column or parameter generates as its enum union type (e.g. `Role`, or `Role | null` when nullable) rather than `string`, and is imported from `models.ts`. ### Options | Option | Default | Description | |----------|---------|-------------| | `client` | `bun` | Database driver the generated code targets: `bun` (Bun's SQL class) or `pg` (node-postgres) | ```sh axel codegen -g ts -o ./gen --option client=pg ``` ### Setup — Bun (default) The default client targets Bun's SQL class. The generated `DB` interface is: ```ts export interface DB { unsafe>(sql: string, params?: unknown[]): Promise; } ``` Bun's `SQL` class satisfies this directly: ```ts const sql = new SQL({ url: "postgres://user:pass@localhost:5432/mydb" }); const runner = new Runner(sql); ``` Any other client works as long as it implements the `DB` interface. ### Setup — node-postgres (`--option client=pg`) With `client=pg` the generated query functions and `Runner` take a [node-postgres](https://node-postgres.com) `Pool` directly and read rows off `db.query(...).rows` — there is no `DB` interface. Install `pg` and its types (`bun add pg @types/pg`), then: ```ts const pool = new Pool({ connectionString: "postgres://user:pass@localhost:5432/mydb" }); const runner = new Runner(pool); ``` The typed query functions accept the same `Pool`: ```ts const user = await getUser(pool, { id: "..." }); // GetUserRow | null ``` ### Typed AQL queries — `runner.query` Compiled `.aql` files are exposed as typed methods under `runner.query`: ```ts // list_post.aql → listPost const posts = await runner.query.listPost(); // posts: ListPostRow[] // get_user.aql with params const user = await runner.query.getUser({ id: "..." }); // user: GetUserRow | null ``` Each method's param and row types live in the corresponding `.ts` file and are re-exported from `runner.ts`. ### Fluent select builder — `runner.select()` For ad-hoc queries, `runner.select()` returns a typed builder. The shape argument controls which fields are returned and is inferred at compile time. ```ts // Select specific fields — return type is inferred from the shape const users = await runner .select("User", { id: true, email: true, name: true }) .all(); // users: Array<{ id: string; email: string; name: string | null }> ``` #### Filtering `.where()` returns a `FilterChain`. Chain `.and()` and `.or()` on it: ```ts const users = await runner .select("User", { id: true, email: true }) .where("active", "=", true) .and("age", ">=", 18) .or("email", "=", "admin@example.com") .all(); ``` `.and()` and `.or()` are only available after `.where()` — calling them directly on `runner.select()` is a compile-time error. #### Nested shapes (links) Pass another builder as a shape value to pull related rows as a JSON array in a single query: ```ts const users = await runner .select("User", { id: true, email: true, posts: runner.select("Post", { title: true, content: true }), }) .all(); // users: Array<{ id: string; email: string; posts: Array<{ title: string; content: string }> }> ``` To filter the sub-select, call `.where()` on the inner builder before passing it: ```ts const users = await runner .select("User", { id: true, posts: runner .select("Post", { title: true }) .where("authorId", "=", "`User.id`"), // backtick = outer-query reference }) .all(); ``` The backtick syntax (`` "`User.id`" ``) is a correlated reference — Axel resolves it to the outer query's alias at SQL-build time, producing a `WHERE p.author = u.id` condition with no extra round-trips. #### `.all()` vs `.one()` ```ts const all = await runner.select("User", { id: true }).all(); // User[] const one = await runner.select("User", { id: true }).where("id", "=", id).one(); // User | null ``` ### Insert builder — `runner.insert()` ```ts const user = await runner .insert("User", { email: "alice@example.com", age: 30 }) .one(); // user: User ``` ### Globals When the schema declares [globals](/asl/globals), the generator emits two ways to set them. Both run the wrapped queries in a transaction that first applies `set_config('app.', …)`. The `Runner` gets a `with` method that scopes a block of queries: ```ts await runner.withCurrentUser(userId, async (q) => { return q.listDocs({ /* … */ }); }); ``` The standalone query functions take an optional trailing options object — useful when you're not going through the `Runner`: ```ts const doc = await createDoc(db, params, { currentUser: userId }); ``` ### Transactions & Custom Connections — `withDb()` To run generated query methods inside an existing transaction or custom connection, use `withDb(db)` on `Runner` or `Queries`. You can obtain a `Queries` instance directly or pass an async callback: ```ts // Direct call: const q = runner.withDb(tx); const doc = await q.createDoc(params); // Callback style: await runner.withDb(tx, async (q) => { const user = await q.createUser(userParams); return q.createDoc({ ...docParams, authorId: user.id }); }); ``` --- ## Go generator (`-g go`) ### Generated files | File | Contents | |------|----------| | `models.go` | One struct per concrete ASL type; enum const blocks | | `.go` | Typed function, params struct, and row struct per AQL query | | `runner.go` | `Runner` + `Queries` structs with schema embedded for dynamic `Run()` | ### Type mapping | AQL type | Go type | Nullable Go type | |------------|---------------|------------------| | `str` | `string` | `*string` | | `int16` | `int16` | `*int16` | | `int32` | `int32` | `*int32` | | `int64` | `int64` | `*int64` | | `float32` | `float32` | `*float32` | | `float64` | `float64` | `*float64` | | `bool` | `bool` | `*bool` | | `uuid` | `string` | `*string` | | `datetime` | `time.Time` | `*time.Time` | | `json` | `interface{}` | `interface{}` | An **enum**-backed column or parameter generates as its enum type (e.g. `Role`, or `*Role` when nullable) rather than `string`. The type is defined in `models.go` in the same package. ### Setup | Option | Default | Description | |-----------|-------------|-------------| | `package` | `generated` | Package name for all generated files | ```sh axel codegen -g go -o ./gen --option package=myapp ``` ### Setup The generated Go uses [pgx](https://github.com/jackc/pgx). `NewRunner` takes a `*pgxpool.Pool`; the typed query functions take a `DBTX` interface (satisfied by both `*pgxpool.Pool` and `pgx.Tx`), so a pool still works everywhere. ```go "context" "github.com/jackc/pgx/v5/pgxpool" gen "myapp/gen" ) db, _ := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/mydb?sslmode=disable") defer db.Close() runner := gen.NewRunner(db) ``` Rows are scanned into the generated structs with `pgx.RowToStructByName` (matched via the `db` struct tag), and nested `json_agg`/`row_to_json` columns decode straight into nested struct/slice fields. ### Typed AQL queries — `runner.Query` Compiled `.aql` files are exposed as typed methods under `runner.Query`: ```go // list_post.aql → Query.ListPost posts, err := runner.Query.ListPost(ctx) // posts: []ListPostRow // get_user.aql with params user, err := runner.Query.GetUser(ctx, GetUserParams{ID: "..."}) // user: *GetUserRow ``` ### Dynamic queries — `runner.Run()` `Run` compiles and executes any AQL string at runtime, returning `[]map[string]any`. JSON columns (nested shapes, `json_agg` results) are automatically decoded. ```go rows, err := runner.Run(ctx, `select User { id, email } filter .active = true`, map[string]any{}) if err != nil { log.Fatal(err) } for _, row := range rows { fmt.Println(row["id"].(string), row["email"].(string)) } ``` Pass parameters by name; they are matched to `$name` placeholders in the AQL: ```go rows, err := runner.Run(ctx, `select User { id, email } filter .email = $email`, map[string]any{"email": "alice@example.com"}, ) ``` ### Globals When the schema declares [globals](/asl/globals), the generator emits two ways to set them; both run the wrapped queries in a transaction that first applies `set_config('app.', …)`. A `Runner` method scopes a block of queries: ```go err := runner.WithCurrentUser(ctx, userID, func(q *gen.Queries) error { _, err := q.ListDocs(ctx, gen.ListDocsParams{ /* … */ }) return err }) ``` The standalone query functions take functional options — for when you're not using the `Runner`: ```go doc, err := gen.CreateDoc(ctx, db, params, gen.WithCurrentUser(userID)) ``` ### Transactions & Custom Connections — `WithDB()` and `NewQueries()` To execute generated query methods inside an existing `pgx.Tx` or custom connection, use `WithDB()` on `Runner` or `Queries`, or construct a `Queries` directly with `NewQueries()`: ```go tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // Via Runner or Queries.WithDB: q := runner.WithDB(tx) // or: q := gen.NewQueries(tx) user, err := q.CreateUser(ctx, userParams) if err != nil { return err } doc, err := q.CreateDoc(ctx, docParams) if err != nil { return err } return tx.Commit(ctx) ``` --- ## Writing a custom generator Generators can be written in **any language**. Axel invokes an external binary, sends a `CodegenRequest` as JSON on stdin, and expects a `CodegenResponse` as JSON on stdout. ```sh axel codegen --plugin ./my-generator -o ./gen ``` ### Protocol **Stdin → `CodegenRequest`** ```json { "schema": { ... }, "queries": [ ... ], "config": { "out_dir": "./gen", "options": { "key": "value" } } } ``` **Stdout ← `CodegenResponse`** ```json { "files": [ { "path": "models.py", "content": "..." }, { "path": "queries.py", "content": "..." } ] } ``` All `path` values are relative to `out_dir`. Axel writes the files after the subprocess exits. Anything written to **stderr** is forwarded to the terminal. A non-zero exit code is treated as an error. ### `CodegenRequest` schema ```ts interface CodegenRequest { schema: SchemaDescriptor; queries: QueryDescriptor[]; config: { out_dir: string; options: Record; }; } interface SchemaDescriptor { scalars: ScalarDescriptor[]; enums: EnumDescriptor[]; types: TypeDescriptor[]; } interface ScalarDescriptor { name: string; // e.g. "EmailStr" base: string; // e.g. "str" sql_type: string; // e.g. "TEXT" } interface EnumDescriptor { name: string; values: string[]; } interface TypeDescriptor { name: string; table: string; // SQL table name; empty for abstract types is_abstract: boolean; extends?: string[]; properties?: PropertyDescriptor[]; links?: LinkDescriptor[]; computed?: ComputedDescriptor[]; indexes?: IndexDescriptor[]; } interface PropertyDescriptor { name: string; column: string; aql_type: string; // e.g. "str", "int32", "datetime" sql_type: string; // e.g. "TEXT", "INTEGER", "TIMESTAMPTZ" is_required: boolean; is_multi: boolean; default?: string; constraints?: { name: string; args?: string[] }[]; } interface LinkDescriptor { name: string; target_type: string; join_column?: string; // FK column name (single link) junction_table?: string; // Junction table name (multi link) is_required: boolean; is_multi: boolean; } interface ComputedDescriptor { name: string; expr: string; // SQL expression template } interface IndexDescriptor { columns: string[]; } ``` ### `QueryDescriptor` schema ```ts interface QueryDescriptor { name: string; // camelCase function name, e.g. "listPost" file: string; // source .aql file path sql: string; // compiled parameterized SQL operation: "select" | "insert" | "update" | "delete"; params?: ParamDescriptor[]; result: ResultDescriptor; } interface ParamDescriptor { name: string; // e.g. "email" aql_type: string; // e.g. "str" enum_type?: string; // enum type name when the param is enum-backed sql_pos: number; // 1-based $N position in the SQL string } interface ResultDescriptor { fields?: ResultField[]; is_multiple: boolean; // true → array result is_scalar: boolean; // true → count/aggregate, no fields } interface ResultField { name: string; aql_type?: string; sql_type?: string; enum_type?: string; // enum type name when the column is enum-backed is_nullable: boolean; is_multiple: boolean; // true → JSON array (multi-link or computed sub-select) target_type?: string; // set for link fields sub_fields?: ResultField[]; } ``` ### Example: Python generator ```python #!/usr/bin/env python3 req = json.load(sys.stdin) schema = req["schema"] queries = req["queries"] files = [] # Generate models lines = ["# Auto-generated by axel\nfrom typing import Optional, Any\n"] for typ in schema["types"]: if typ["is_abstract"]: continue lines.append(f"class {typ['name']}:") for prop in typ.get("properties", []): py_type = {"str": "str", "int32": "int", "bool": "bool"}.get(prop["aql_type"], "Any") if not prop["is_required"]: py_type = f"Optional[{py_type}]" lines.append(f" {prop['name']}: {py_type}") lines.append("") files.append({"path": "models.py", "content": "\n".join(lines)}) # Generate query stubs for q in queries: params = ", ".join(p["name"] for p in q.get("params", [])) lines = [ "# Auto-generated by axel", f"SQL = \"\"\"\n{q['sql']}\n\"\"\"", "", f"def {q['name']}(db{', ' + params if params else ''}):", f" return db.execute(SQL{', [' + params + ']' if params else ''})", ] files.append({"path": f"{q['name']}.py", "content": "\n".join(lines)}) json.dump({"files": files}, sys.stdout) ``` Make the script executable and point `--plugin` at it: ```sh chmod +x ./gen.py axel -d ./myproject codegen --plugin ./gen.py -o ./gen ``` ### Example: Go native generator Native Go generators implement the `codegen.Generator` interface and self-register via `init()`. This is how the built-in `go` and `ts` generators work. ```go package mygen "fmt" "bytes" "github.com/struckchure/axel/core/codegen" ) func init() { codegen.Register(&MyGenerator{}) } type MyGenerator struct { buf bytes.Buffer } func (g *MyGenerator) Name() string { return "mygen" } func (g *MyGenerator) BeginSchema(_ *codegen.Context, _ codegen.SchemaDescriptor) error { g.buf.Reset() return nil } func (g *MyGenerator) BeginType(_ *codegen.Context, t codegen.TypeDescriptor) error { if !t.IsAbstract { fmt.Fprintf(&g.buf, "type %s struct {\n", t.Name) } return nil } func (g *MyGenerator) OnProperty(_ *codegen.Context, p codegen.PropertyDescriptor) error { fmt.Fprintf(&g.buf, "\t%s string\n", p.Name) return nil } func (g *MyGenerator) EndType(_ *codegen.Context) error { g.buf.WriteString("}\n\n") return nil } func (g *MyGenerator) EndSchema(ctx *codegen.Context) error { return ctx.WriteFile("models.xyz", g.buf.Bytes()) } // Unused hooks — must still be implemented. func (g *MyGenerator) OnScalar(_ *codegen.Context, _ codegen.ScalarDescriptor) error { return nil } func (g *MyGenerator) OnEnum(_ *codegen.Context, _ codegen.EnumDescriptor) error { return nil } func (g *MyGenerator) OnLink(_ *codegen.Context, _ codegen.LinkDescriptor) error { return nil } func (g *MyGenerator) OnComputed(_ *codegen.Context, _ codegen.ComputedDescriptor) error { return nil } func (g *MyGenerator) OnIndex(_ *codegen.Context, _ codegen.IndexDescriptor) error { return nil } func (g *MyGenerator) OnQuery(_ *codegen.Context, _ codegen.QueryDescriptor) error { return nil } ``` Register it with a blank import in your `cmd/` package (after forking the repo or embedding Axel as a library): ```go ``` Then use it like any built-in: ```sh axel -d ./myproject codegen -g mygen -o ./gen ``` ### Hook call order ``` BeginSchema OnScalar (each custom scalar, alphabetical) OnEnum (each enum, alphabetical) BeginType (each type, alphabetical — abstract types included) OnProperty / OnLink / OnComputed / OnIndex (each member, declaration order) EndType OnQuery (each AQL query, in discovery order) EndSchema ``` Use `BeginSchema` to reset state, `BeginType`/`EndType` to open and close type-level buffers, and `EndSchema` to flush everything to files via `ctx.WriteFile`. `ctx.WriteFile(path, content)` writes `content` to `/`, creating parent directories as needed. Paths are relative to `out_dir`. --- Source: https://struckchure.github.io/axel/asl # Axel Schema Language (ASL) ASL is a declarative schema language for defining PostgreSQL types. You write `.asl` files; Axel compiles them into migration SQL that you apply with `axel diff` and `axel up`. ``` schema.asl ``` A schema can also be [split across several files](/asl/splitting) with a glob such as `schema/*.asl`. ## How ASL is organized An ASL file is a set of top-level declarations. The reference is split by feature: - **[Schema](/asl/schema)** — concrete and abstract types, inheritance (`extends`), indexes, and composite constraints. - **[Data Types](/asl/data-types)** — built-in scalars, named scalar aliases, typed JSON, and enums. - **[Fields](/asl/fields)** — properties, defaults, rewrites, field constraints, links, and computed fields. - **[Functions](/asl/functions)** — top-level Postgres functions with AQL or raw-SQL bodies. - **[Triggers](/asl/triggers)** — row/statement triggers attached to a type. - **[Splitting a Schema](/asl/splitting)** — spread declarations across several `.asl` files. ## Complete example A schema that touches most features: ```asl scalar type EmailStr extends str; enum Role { Admin, Member, Guest } abstract type Base { required id: uuid { default := gen_uuid(); constraint pk; }; required created_at: datetime { default := datetime_current(); }; required updated_at: datetime { default := datetime_current(); rewrite update := datetime_current(); }; } type User extends Base { required email: EmailStr { constraint exclusive; }; name: str; required age: int32; active: bool { default := true }; required role: Role; computed display_name := .name ?? .email; index on (.email); } type Post extends Base { required title: str; required content: str; required link author: User; multi link likes: User; } type Comment extends Base { required link post: Post; required link author: User; required content: str; } ``` --- Source: https://struckchure.github.io/axel/asl/schema/types # Types ## Concrete types A concrete type maps to a database table. ```asl type User { required email: str; name: str; required age: int32; } ``` The keyword `model` is accepted as a synonym for `type`. ## Abstract types Abstract types have no table of their own. They exist only to be extended by other types. ```asl abstract type Timestamped { required id: uuid { default := gen_uuid(); constraint pk; }; required created_at: datetime { default := datetime_current(); }; required updated_at: datetime { default := datetime_current(); rewrite update := datetime_current(); # keep it fresh on every UPDATE }; } ``` > `default` only fires on INSERT, so without the `rewrite` line `updated_at` would > never change. See [Rewrites](/asl/fields/rewrites). Abstract types are meant to be reused through [inheritance](/asl/schema/inheritance). --- Source: https://struckchure.github.io/axel/asl/schema/inheritance # Inheritance A type can extend one or more other types. All properties, links, indexes, and computed fields are inherited. ```asl type User extends Timestamped { required email: str; } type Admin extends User, Audited { required level: int32; } ``` > **Deprecation Notice:** The `extending` keyword is deprecated in favor of `extends`. Running `axel fmt` will automatically migrate `extending` to `extends`. Inheriting from an [abstract type](/asl/schema/types#abstract-types) is the common way to share a common `id` / `created_at` / `updated_at` base across many concrete types. --- Source: https://struckchure.github.io/axel/asl/schema/indexes # Indexes ```asl type User { required email: str; required age: int32; active: bool; index on (.email); index on (.active, .age); } ``` Each `index on (...)` declaration generates a `CREATE INDEX` statement in the migration SQL. --- Source: https://struckchure.github.io/axel/asl/schema/constraints # Type-level constraints In addition to [field-level constraints](/asl/fields/constraints), a `constraint on (.a, .b);` declaration inside a type body applies a constraint across one or more columns. This is how you express composite constraints such as unique-together. ```asl type Membership { required user_id: uuid; required org_id: uuid; required code: str; constraint exclusive on (.user_id, .org_id); # composite UNIQUE (unique together) constraint min_length(4) on (.code); # CHECK on char_length } ``` Supported expressions: `exclusive` → composite `UNIQUE`, `pk` → composite `PRIMARY KEY`, `min_length(n)` / `max_length(n)` → `char_length` `CHECK`. Constraints are emitted with deterministic names (e.g. `uq_membership_user_id_org_id`) inside `CREATE TABLE`, and adding or removing one on an existing type generates an `ALTER TABLE ... ADD/DROP CONSTRAINT` in the migration SQL. ## Partial (filtered) unique constraints An `exclusive` constraint can carry a `filter ` clause, making it a **partial** unique constraint — the uniqueness only applies to rows matching the predicate. The predicate is a native [AQL](/aql/) expression (the same language as [policies](/asl/policies) and query filters), including `Enum.Member` references. ```asl enum QueueStatus { Pending, Running, Done } type Job { required name: str; required actor: str; required status: QueueStatus; # At most one Pending job per (name, actor); Running/Done rows are unconstrained. constraint exclusive on (.name, .actor) filter .status = QueueStatus.Pending; } ``` Postgres can't put a `WHERE` on a table constraint, so a filtered `exclusive` lowers to a **partial unique index** rather than a `CONSTRAINT … UNIQUE`: ```sql CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_name_actor" ON "job" ("name", "actor") WHERE (status = 'Pending'); ``` See the [job queue example](/examples/job-queue) for the full pattern. --- Source: https://struckchure.github.io/axel/asl/data-types/scalars # Built-in scalars | ASL type | PostgreSQL type | |------------|--------------------| | `str` | `TEXT` | | `int16` | `SMALLINT` | | `int32` | `INTEGER` | | `int64` | `BIGINT` | | `float32` | `REAL` | | `float64` | `DOUBLE PRECISION` | | `bool` | `BOOLEAN` | | `uuid` | `UUID` | | `datetime` | `TIMESTAMPTZ` | | `date` | `DATE` | | `time` | `TIME` | | `json` | `JSON` | | `jsonb` | `JSONB` | | `bytes` | `BYTEA` | | `decimal` | `NUMERIC` | ## `json` vs `jsonb` Axel explicitly distinguishes between PostgreSQL's `JSON` and `JSONB` types: * **`jsonb` (`JSONB`)**: Stored in a decomposed binary format. Preferred for structured data that must be queried, filtered, searched, or indexed (GIN indexes, expression indexes). * **`json` (`JSON`)**: Stored as exact text. Use only when the original JSON formatting, key ordering, or duplicate keys must be preserved, or when the payload is completely opaque. ## Using `uuid` with `pgcrypto` To generate UUIDs automatically (e.g. `default := gen_random_uuid();` or `gen_uuid()`), enable the `pgcrypto` PostgreSQL extension in your schema: ```asl use extension 'pgcrypto'; type User { required id: uuid { default := gen_random_uuid(); constraint pk; }; required email: str; } ``` --- Source: https://struckchure.github.io/axel/asl/data-types/aliases # Named scalar aliases & Extended Types Create a named alias or extended scalar type over a built-in [scalar](/asl/data-types/scalars) or another scalar type using `extends`. ```asl scalar type EmailStr extends str; scalar type Score extends float32; ``` > **Deprecation Notice:** The `extending` keyword is deprecated in favor of `extends`. Running `axel fmt` will automatically migrate `extending` to `extends`. ### Extended Scalars with Field Descriptors Scalar types can define field descriptors (`constraint`, `default`, `rewrite`) inside their body block `{ ... }`, just like properties in object type models. ```asl scalar type Code extends str { constraint min_length(6); constraint max_length(6); default := random_hex(6); } scalar type AutoTimestamp extends datetime { rewrite update := datetime_current(); } ``` Any object type property declared with an extended scalar automatically inherits all of its field descriptors: ```asl type Product { required id: uuid { constraint pk; }; # Inherits min_length(6), max_length(6), and default random_hex(6): code: Code; # Properties can override the default and add additional constraints: custom_code: Code { default := '999999'; constraint exclusive; }; # Inherits the BEFORE UPDATE trigger rewrite: updated_at: AutoTimestamp; } ``` ### Chained Scalar Inheritance Scalar types can extend other user-defined scalar types, inheriting constraints, defaults, and rewrites in a chain: ```asl scalar type ShortStr extends str { constraint max_length(10); } scalar type ExactCode extends ShortStr { constraint min_length(6); default := '000000'; } ``` --- # Custom SQL Extension Scalars For PostgreSQL extension types (such as PostGIS `geography`, `geometry`, `pgvector` `vector`, `citext`, `ltree`), declare custom SQL scalars with `extends sql ""`. You can optionally supply client-side representation typing using `as`: ```asl # Record representation: generates interfaces/structs in codegen, enables AQL dot-access (.location.latitude) scalar type Point extends sql "geography(Point, 4326)" as { latitude: float32; longitude: float32; }; # Codec functions for reading and writing: function (p Point) deserialize() Point { return Point{ latitude: ST_Y(p::geometry), longitude: ST_X(p::geometry) }; }; function (p Point) serialize() { return ST_SetSRID(ST_MakePoint(p.longitude, p.latitude), 4326); }; # Multi-dimensional array representation: generates number[] / []float32 in codegen scalar type Embedding extends sql "vector(1536)" as multi float32; # Primitive scalar mapping: generates string in codegen scalar type Citext extends sql "citext" as str; # Opaque type: defaults to string in codegen scalar type Geometry extends sql "geometry"; ``` ### How `deserialize` and `serialize` work: - **Zero-Overhead Read Inlining**: In `select` queries, `deserialize()` inlines PostGIS extraction via `json_build_object(...)` directly on the database side. - **SQL-Side Write Serialization**: In `insert` and `update` queries, passing a `{ latitude, longitude }` object or parameter is inlined using `serialize()` (`ST_SetSRID(ST_MakePoint(...), 4326)`) directly on the database side. - **Backwards Compatibility**: Legacy inline extraction `latitude: float32 := ST_Y(__self__::geometry);` is fully supported and auto-migrated by `axel fmt`. --- # Typed JSON Scalars You can define structured, typed JSON and JSONB scalars that give type safety, autocomplete, and query capabilities to document data in PostgreSQL. ```asl scalar type Coordinate extends json { lat: str; lng: str; } scalar type ItemStats extends jsonb { score: float64; views: int32; multi tags: str; } scalar type VendorAvailability extends jsonb { required day: str; opening: time; closing: time; effective_from: date; updated_at: datetime; } ``` ### Allowed Field Types To ensure reliable extraction and type coercion in PostgreSQL, field types within typed JSON scalars are strictly restricted to: - **Strings**: `str` - **Numbers**: `int16`, `int32`, `int64`, `float32`, `float64`, `decimal` - **Temporal**: `date`, `time`, `datetime` - **Arrays**: Provided via the `multi` modifier (e.g. `multi tags: str;`, `multi scores: float64;`) Nested JSON objects and non-primitive types are not permitted within typed JSON scalars. #### Temporal fields JSON has no native date/time type, so temporal fields are stored in the document as strings (`"09:00:00"`, `"2026-01-31"`, `"2026-01-31T08:30:00Z"`) and cast on extraction, exactly like numeric fields: ```aql # ((availability->>'opening')::TIME) select Vendor { id, name } filter .availability.opening <= $now; # ((availability->>'updated_at')::TIMESTAMPTZ) select Vendor { id, name } filter .availability.updated_at >= $since; ``` Because the underlying document value is a string, generated clients type these fields as strings (`string` in TypeScript, `string` in Go) rather than `Date` / `time.Time` — the same treatment `decimal` already gets. Query **parameters** compared against them keep their real temporal type (`Date` in TypeScript, `time.Time` in Go), since those are bound as SQL values. ### Using Typed JSON Scalars in Types Declare properties with your typed JSON scalar: ```asl type Place { required id: uuid; name: str; coord: Coordinate; stats: ItemStats; } ``` ### Querying in AQL You can query and filter on individual fields of a typed JSON scalar directly using dot-notation: ```aql # String comparison automatically extracts as text (coord->>'lat') select Place { id, name } filter .coord.lat = $lat; # Numeric comparisons automatically apply PostgreSQL type casting ((stats->>'score')::DOUBLE PRECISION) select Place { id, name } filter .stats.score > $min_score; # Integer comparisons select Place { id, name } filter .stats.views >= 100; ``` ### Generated Client Types Axel generates idiomatic types for all target languages: #### TypeScript ```typescript export interface Coordinate { lat?: string | null; lng?: string | null; } export interface ItemStats { score?: number | null; views?: number | null; tags?: string[] | null; } export interface VendorAvailability { closing?: string | null; day: string; effectiveFrom?: string | null; opening?: string | null; updatedAt?: string | null; } export interface Place { id: string; name?: string | null; coord?: Coordinate | null; stats?: ItemStats | null; } ``` #### Go ```go type Coordinate struct { Lat *string `json:"lat"` Lng *string `json:"lng"` } type ItemStats struct { Score *float64 `json:"score"` Views *int32 `json:"views"` Tags []string `json:"tags"` } type VendorAvailability struct { Closing *string `json:"closing"` Day string `json:"day"` EffectiveFrom *string `json:"effective_from"` Opening *string `json:"opening"` UpdatedAt *string `json:"updated_at"` } type Place struct { ID string `json:"id" db:"id"` Name *string `json:"name" db:"name"` Coord *Coordinate `json:"coord" db:"coord"` Stats *ItemStats `json:"stats" db:"stats"` } ``` --- Source: https://struckchure.github.io/axel/asl/data-types/enums # Enum types Enums are stored as `TEXT` with a named `CHECK (col IN (...))` constraint (`chk___enum`) restricting the column to the declared values. ```asl enum Role { Admin, Member, Guest } ``` Use an enum as a property type. Reference an enum value in a default with the qualified `Enum.Member` form (a quoted literal `'Member'` is also accepted); the value is validated against the enum's declaration at `generate` time: ```asl type User { required role: Role { default := Role.Member; }; } ``` This emits `"role" TEXT NOT NULL DEFAULT 'Member' CONSTRAINT "chk_user_role_enum" CHECK ("role" IN ('Admin', 'Member', 'Guest'))`. Generated Go/TS code uses the enum type for the field (`Role`) rather than a plain string — for model structs, query **parameters**, and query **result columns** alike (including columns pulled in by a `*` splat and inside nested sub-select rows). --- Source: https://struckchure.github.io/axel/asl/fields/properties # Properties Properties map to columns. ```asl type User { required email: str; # NOT NULL column name: str; # nullable column required property age: int32; # "property" keyword is optional } ``` ## Required `required` maps to `NOT NULL`. ## Defaults ```asl active: bool { default := true }; name: str { default := 'anonymous'; }; score: int32 { default := 0; }; # Functions id: uuid { default := gen_uuid(); }; created_at: datetime { default := datetime_current(); }; ``` A `default` runs once, on INSERT. To re-assign a field on later updates, see [Rewrites](/asl/fields/rewrites). Declaring a default also matters when you **add** a required field to a table that already has rows: with one, Axel backfills existing rows itself; without one, the migration leaves a seam you must fill in. See [`axel diff`](/cli#axel-diff). ## Multi (array) properties `multi` on a property declares a PostgreSQL **array column** — one column, not a junction table. (`multi` on a *link* means something else entirely; see [Links](/asl/fields/links).) ```asl type User { multi roles: UserType; # TEXT[] with a containment check multi images: str; # TEXT[] multi scores: float64; # DOUBLE PRECISION[] } ``` ```sql "roles" TEXT[] CONSTRAINT "chk_user_roles_enum" CHECK ("roles" <@ ARRAY['Admin', 'Runner', 'Vendor']::TEXT[]), "images" TEXT[], "scores" DOUBLE PRECISION[] ``` An enum array is stored as `TEXT[]` guarded by a containment check rather than as a Postgres enum array. Two consequences worth knowing: - **Membership compiles to `= ANY(...)`**, not `IN` — Postgres `IN` takes a parenthesised list and rejects an array operand. ```aql select User { id } filter UserType.Admin in .roles; # → 'Admin' = ANY(u.roles) ``` - **Generated clients type it as an array**, with the nullability outside the array: `roles?: UserType[] | null` in TypeScript, `[]UserType` in Go — in the models and in query result rows alike. --- Source: https://struckchure.github.io/axel/asl/fields/rewrites # Rewrites A [`default`](/asl/fields/properties#defaults) runs once, on INSERT. A `rewrite` re-assigns the field on the events you name — the mechanism behind an auto-updating `updated_at`: ```asl updated_at: datetime { default := datetime_current(); rewrite update := datetime_current(); # events: insert, update (comma-separated) }; ``` The value may be a builtin function (`datetime_current()` → `now()`), a literal, a row-reference column — `__new__.` / `__subject__.` (the row being written) or `__old__.` (the pre-update row, `UPDATE` only) — or a call to a declared [function](/asl/functions) passing row references and literals: ```asl slug: str { rewrite create, update := __new__.title; }; # NEW."title" slug: str { rewrite create, update := slugify(__new__.title); }; # slugify(NEW."title") ``` Events are `insert` / `update`; `create` is accepted as an alias for `insert`. A rewrite belongs to the type that **declared** it, and generates one function per declaring model, named `axel_rw__`. An `updated_at` rewrite on an abstract `Base` becomes a single `axel_rw_base_1` that **every** concrete type inheriting it shares — each concrete table gets its own `BEFORE` trigger that `EXECUTE`s that one function. A rewrite a concrete type declares itself is a separate function (`axel_rw__1`), so a type that both inherits and declares rewrites simply gets one trigger per contributing model. See [Triggers](/asl/triggers) for the general mechanism. --- Source: https://struckchure.github.io/axel/asl/fields/constraints # Field constraints ```asl email: str { constraint exclusive; # UNIQUE constraint min_length(5); constraint max_length(100); }; id: uuid { constraint pk; # PRIMARY KEY constraint exclusive; # UNIQUE }; ``` `min_length(n)` and `max_length(n)` apply to string columns and are emitted as a named `CHECK (char_length("col") >= n)` / `<= n`. All field-level constraints carry a deterministic name — enum and length `CHECK`s (`chk_
__`), single-column `UNIQUE` (`uq_
_`), primary keys (`pk_
`), and foreign keys (`fk_
_`). That same name is used both inside `CREATE TABLE` and in any later `ALTER TABLE ... ADD/DROP CONSTRAINT`, so a constraint created with a table can be dropped by name on a schema change or rollback. For constraints that span multiple columns, see [type-level constraints](/asl/schema/constraints). --- Source: https://struckchure.github.io/axel/asl/fields/links # Links Links define foreign-key relationships between types. ## Single link (FK column) ```asl type Post { required link author: User; # adds an FK column named "author" } ``` The column is named after the **field**, not the target type, and carries no `_id` suffix — `link author: User` gives you `post.author`, referencing `user.id`. Filters and shapes still use the field name (`.author`, `author: { … }`), so the column name only matters when you read the generated SQL. ## Multi link (junction table) ```asl type Post { multi link tags: Tag; # creates post_tags junction table } ``` The junction table name is `{source}_{link}` in snake_case (e.g. `post_tags`), and its two FK columns are named after the tables they reference: ```sql CREATE TABLE "post_tags" ( "post" UUID NOT NULL, "tag" UUID NOT NULL, CONSTRAINT "pk_post_tags" PRIMARY KEY ("post", "tag"), CONSTRAINT "fk_post_tags_post" FOREIGN KEY ("post") REFERENCES "post"("id") ON DELETE CASCADE, CONSTRAINT "fk_post_tags_tag" FOREIGN KEY ("tag") REFERENCES "tag"("id") ON DELETE CASCADE ); ``` ### Self-referential multi links When a multi link points back at its own type, naming both sides after the referenced table would produce two columns called `product`, which Postgres rejects (`column "product" appears twice in primary key constraint`). The target side falls back to the **link name**: ```asl type Product { required id: uuid { constraint pk; }; multi link addons: Product; # product_addons("product", "addons") } ``` Nothing about querying changes — `select Product { id, addons: { id } }` works as it does for any other multi link. ## Multi scalars are not links `multi` on a scalar field means something different from `multi link`: it declares an **array column** on the row, not a junction table. ```asl type User { multi link teams: Team; # junction table "user_teams" multi roles: UserType; # a single TEXT[] column "roles" } ``` The distinction shows up in two places: - **Membership.** `in` against a multi scalar compiles to `= ANY(...)`, because Postgres `IN` takes a parenthesised list rather than an array. Against a multi link it compiles to an `EXISTS` over the junction table. ```aql select User { id } filter UserType.Admin in .roles; # → 'Admin' = ANY(u.roles) select User { id } filter $team in .teams; # → EXISTS (SELECT 1 FROM "user_teams" …) ``` - **Assignment.** Delta assignment (`{ "+": …, "-": … }`) applies only to a multi link. A multi scalar is assigned as a whole array — see [Updating links](/aql/update/links). ## Required links ```asl type Comment { required link post: Post; # column "post", NOT NULL required link author: User; # column "author", NOT NULL } ``` Adding a **required** link to a table that already has rows needs a backfill, exactly like adding a required column — see [`axel diff`](/cli#axel-diff). --- Source: https://struckchure.github.io/axel/asl/fields/computed # Computed fields Computed fields are not stored as columns. They are expanded inline during AQL compilation. ```asl type User { required email: str; name: str; computed display_name := .name ?? .email; } type OrderItem { required quantity: int32; required unit_price: decimal; discount: decimal; computed total := (.quantity * .unit_price) - .discount; computed tax := .unit_price * 0.2; } ``` Computed fields support arithmetic operators (`+`, `-`, `*`, `/`), unary signs (`+`, `-`), function calls, and `??` (null coalescing). They can be selected in AQL shapes and queried just like ordinary fields. --- Source: https://struckchure.github.io/axel/asl/functions # Functions A top-level `function` declares a Postgres function. Axel emits it as `CREATE OR REPLACE FUNCTION` — mapped **directly** to Postgres, not aliased, so it is callable from queries, other functions, triggers, defaults, and checks. ```asl use extension 'unaccent'; @language plpgsql @immutable @strict @parallel safe function slugify(value: text) -> text { return regexp_replace( regexp_replace(lower(public.unaccent(value)), '[^a-z0-9\-_]+', '-', 'gi'), '(^-+|-+$)', '', 'g' ); }; ``` lowers to: ```sql CREATE OR REPLACE FUNCTION "slugify"(value text) RETURNS text AS $$ BEGIN RETURN regexp_replace( regexp_replace(lower(public.unaccent(value)), '[^a-z0-9\-_]+', '-', 'gi'), '(^-+|-+$)', '', 'g' ); END; $$ LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE; ``` ## Parameters and types Parameters are `name: type`. Types map **directly to Postgres**: ASL scalars (`str`, `int32`, …) and your own scalar aliases/enums map to their SQL type (`str` → `TEXT`), and **any other name passes through verbatim** as a raw Postgres type. A trailing `[]` makes an array. ```asl @language sql function first_tag(tags: text[]) -> text { return tags[1]; }; @language sql function total(a: int32, b: int32) -> int32 { return a + b; }; ``` ## The body A function body is a single `return ;`. The expression is **raw Postgres**, passed through verbatim, and axel wraps it for you: | `@language` | wrapper | |---|---| | `plpgsql` (default) | `BEGIN RETURN ; END;` | | `sql` | `SELECT ;` | ```asl @language sql function full_name(first: text, last: text) -> text { return first || ' ' || last; }; ``` Everything in the expression — operators, function calls (`lower`, `regexp_replace`, `public.unaccent`), casts, subqueries — passes straight through to Postgres, so the full SQL surface is available. For multi-statement logic (mutations that also return a row), use a [trigger](/asl/triggers) with an inline `do ( … )` body. ## Calling functions in Defaults & Extended Types Schema-defined functions and builtins can be called in property defaults or [extended scalar types](/asl/data-types/aliases#extended-scalars-with-field-descriptors): ```asl @language sql @volatile @strict function random_hex(len: int32) -> str { return substr(encode(gen_random_bytes(ceil(len / 2.0)::integer), 'hex'), 1, len); }; scalar type Code extends str { constraint min_length(6); constraint max_length(6); default := random_hex(6); } type User { required id: uuid { constraint pk; }; code: Code; referral_code: str { default := random_hex(8); }; } ``` ### Parameter Count & Type Validation Axel validates function calls at compile time: - **Argument count**: The number of arguments passed must match the function's parameter list. - **Argument types**: Literal arguments are checked against declared parameter types (e.g. passing a string literal `'6'` to an `int32` parameter will raise a type mismatch error). ## Calling functions in AQL Queries Declared functions can be called directly in AQL query expressions, filters, and mutations: ```aql @name CreateUserWithCode insert User { email := $email, referral_code := random_hex(8) }; ``` ## Language Server (LSP) Features The Axel Language Server provides rich IntelliSense for schema functions: - **Auto-Completion**: Suggests available schema functions with their parameter signatures in expressions and trigger `execute` clauses. - **Hover**: Displays the clean ASL function signature including decorators (`@language`, `@volatile`, `@strict`, `@parallel`). - **Go to Definition**: Navigates from query call sites and schema default/rewrite expressions across files directly to the `function` declaration. - **Diagnostics**: Flags parameter count mismatches and argument type errors in real time in your editor. ## Inline AQL: aql`…` Some Postgres functions take **SQL as a string** — `cron.schedule`, `EXECUTE`, `dblink`. Writing that SQL by hand means hand-maintaining table and column names that your schema already knows. An aql`…` literal lets you write [AQL](/aql/) instead: axel compiles it while generating the migration and inlines the result as a quoted SQL string. Both forms below are valid, and emit the same migration: ```asl @for KV function kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', 'DELETE FROM "kv" WHERE expires_at < now()'); }; @for KV function kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', aql`delete KV filter .expires_at < now()`); }; ``` ```sql CREATE OR REPLACE FUNCTION "kv_gc"() RETURNS BIGINT AS $$ BEGIN RETURN cron.schedule('kv-gc', '0 * * * *', 'DELETE FROM "kv" k WHERE k.expires_at < now();'); END; $$ LANGUAGE plpgsql; ``` The literal is a **compile-time** construct — nothing about it survives into the database. What you get for it: - The query is checked against your schema. A renamed property or a deleted type fails `axel diff` (and is underlined in the editor) instead of failing at 3am inside a cron job. - Table and column names come from the schema, so `snake_case` derivation and quoting are handled the same way `CREATE TABLE` handles them. Two rules: - **No query parameters.** The compiled SQL is embedded as a literal, so there is nothing to bind `$name` to — a parameterized inline query is an error. Take a function parameter and concatenate if you need a value. - **Backticks are the delimiter**, and the `aql` tag is required — a bare `` `…` `` is a parse error. An inline query is a complete AQL statement, so any of `select` / `insert` / `update` / `delete` works. The trailing `;` is optional. ## Directives (attributes) Attributes are declared as directives **above** the function, decorator-style. The value is omitted for flags and given for the rest: | Directive | Emits | |---|---| | `@language ` | `LANGUAGE ` (default `plpgsql`; `sql` for pure-expression functions) | | `@immutable` / `@stable` / `@volatile` | the volatility class | | `@strict` | `STRICT` (returns null on any null argument) | | `@leakproof` | `LEAKPROOF` | | `@parallel safe` / `unsafe` / `restricted` | `PARALLEL …` | | `@security definer` / `invoker` | `SECURITY …` | | `@cost ` | `COST ` | | `@for ` | nothing directly — marks a **run-once** setup function, invoked once (`SELECT fn();`) in the migration that first creates it, and tags it to ``. See [Policies](/asl/policies#pairing-with-a-one-time-setup-for). | Attributes are emitted in a fixed order, so re-ordering directives never produces a spurious migration. ## Trigger functions A `-> trigger` function takes no parameters (Postgres rule) and is what a [trigger](/asl/triggers)'s `execute` form runs. Its `return` yields the row: ```asl function stamp() -> trigger { return NEW; }; type Post { id: uuid { default := gen_uuid(); }; title: str; trigger t before insert execute stamp(); } ``` Functions are emitted as `CREATE OR REPLACE FUNCTION`; editing a definition produces a single replace in the migration. ## Receiver Functions (Custom Scalar Codecs) Functions can be declared with a receiver on custom scalar types to define read/write codec mappings: ```asl scalar type Point extends sql "geography(Point, 4326)" as { latitude: float32; longitude: float32; }; function (p Point) deserialize() Point { return Point{ latitude: ST_Y(p::geometry), longitude: ST_X(p::geometry) }; }; function (p Point) serialize() { return ST_SetSRID(ST_MakePoint(p.longitude, p.latitude), 4326); }; ``` - **`deserialize()`**: Maps the database representation (`p`) to the typed structured projection. The AQL compiler inlines this as `json_build_object(...)` on `select` queries. - **`serialize()`**: Maps client-supplied objects `{ latitude, longitude }` into the database SQL representation. The AQL compiler inlines this into `insert` and `update` queries. --- Source: https://struckchure.github.io/axel/asl/triggers # Triggers A `trigger` inside a type body attaches to that table. Its body is either an inline AQL statement (`do ( … )`, the default) or a reference to a declared [function](/asl/functions) (`execute ()`). ```asl type Application extends Base { required name: str; # Inline AQL — __new__.field is validated against Application trigger audit after insert, update, delete do ( insert AuditLog { table_name := 'application', action := event, new_data := to_jsonb(__new__) } ); # Or reference a declared function trigger touch before update execute slugify_name(); } ``` Timing is `before` | `after`; events are a comma-separated list of `insert` / `update` / `delete`. Optional clauses: `for each row` (default) or `for each statement`, and `when ( $$ $$ )`. An inline `do` body compiles to a generated function plus the trigger; the SQL is ordered extensions → tables → functions → triggers so a body that writes to another table sees it exist. > On a `DELETE`, `NEW` is null — don't reference `__new__` in a delete-only path. See [Functions](/asl/functions) for the magic identifiers (`__new__`, `event`, …) available inside an inline `do` body. --- Source: https://struckchure.github.io/axel/asl/policies # Policies A `policy` inside a type body declares a Postgres **row-level security** policy on that table. Axel emits a `CREATE POLICY` and enables RLS on the table (`ALTER TABLE … ENABLE ROW LEVEL SECURITY`). Policies filter which rows a role can read or write — the classic use being to hide rows a query shouldn't see. ```asl type KV { required key: str { constraint exclusive; }; required value: json; expires_at: datetime; # Hide rows past their TTL from SELECT (visible = not-yet-expired) policy hide_expired for select using ( .expires_at is null or .expires_at >= now() ); } ``` lowers to: ```sql ALTER TABLE "kv" ENABLE ROW LEVEL SECURITY; CREATE POLICY "hide_expired" ON "kv" FOR SELECT USING (expires_at IS NULL OR expires_at >= now()); ``` ## Syntax ``` policy for , … [to , …] [using ( … )] [with check ( … )]; ``` - **`for , …`** — one or more of `select`, `insert`, `update`, `delete`, or `all`. Postgres allows a single command per `CREATE POLICY`, so a list like `for update, delete` **expands to one policy per command** — the generated policies are suffixed (`_update`, `_delete`) to keep their names unique. A single-command policy keeps its declared name. - **`to , …`** — the roles the policy applies to. Omit for `PUBLIC`. - **`using ( … )`** — predicate for **existing** rows: which rows are visible to `SELECT`/`UPDATE`/`DELETE`. A row is visible when the predicate is true. - **`with check ( … )`** — predicate for **new/updated** rows on `INSERT`/`UPDATE`; a write is rejected when it's false. At least one of `using` / `with check` is required. :::caution[Which clause each command accepts] Postgres restricts the clauses per command, and Axel validates this at `axel validate` / `axel diff` time (before it ever reaches the database): - `using` — `select`, `update`, `delete`, `all` - `with check` — `insert`, `update`, `all` So `policy p for delete with check ( … )` is rejected up front (a `DELETE` has no new row to check) — use `using ( … )` to block deletes instead. See the [append-only log example](/examples/append-only-log). ::: A multi-command policy lowers to one statement per command: ```asl type Event { required topic: str; policy append_only for update, delete using ( false ); } ``` ```sql CREATE POLICY "append_only_update" ON "event" FOR UPDATE USING (false); CREATE POLICY "append_only_delete" ON "event" FOR DELETE USING (false); ``` ## The predicate Predicates are **native [AQL](/aql/) expressions** — the same language used in query `filter` clauses — resolved and type-checked against the type. A `.field` reference resolves to that field's column; `and`/`or`, comparisons, `is null` / `is not null`, `??`, casts (``), and function calls (`now()`, `current_user`) all work as in any AQL filter. ```asl global current_user: uuid; type Doc { required owner: uuid; required title: str; policy owner_only for all to app_user using ( .owner = global current_user ) with check ( .owner = global current_user ); } ``` lowers to (see [Globals](/asl/globals) for how `global current_user` becomes a session read): ```sql CREATE POLICY "owner_only" ON "doc" FOR ALL TO app_user USING (owner = current_setting('app.current_user', true)::UUID) WITH CHECK (owner = current_setting('app.current_user', true)::UUID); ``` ## Traversing links A predicate can follow links, not just read the policy's own columns. **To-one chains** — `.organization.owner`, `.organization.owner.email` — lower to a correlated subquery over the linked table: ```asl type User { required email: str; } type Organization { link owner: User; } type Workflow { required name: str; link organization: Organization; policy owner_only for all to app_user using ( .organization.owner = global current_user ); } ``` lowers the `USING` clause to: ```sql (SELECT o.owner FROM "organization" o WHERE o.id = "workflow".organization LIMIT 1) = current_setting('app.current_user', true)::UUID ``` **Membership** — ` in .` — tests whether a value is among the rows reached through a multi-link, lowered to an `IN (SELECT …)` over the junction table: ```asl type User { required email: str; } type Organization { required name: str; multi members: User; policy member_can_read for select to app_user using ( global current_user in .members ); } ``` lowers the `USING` clause to: ```sql current_setting('app.current_user', true)::UUID IN ( SELECT u.id FROM "organization_members" jt JOIN "user" u ON u.id = jt.user WHERE jt.organization = "organization".id ) ``` A multi-link can only appear as the right side of `in` (it's a set, not a value); using one in a scalar path — `.members.email = …` — is an error. One limit remains: - **No bind parameters.** A policy can't take a `$param`; pull request-scoped values in through a [`global`](/asl/globals) instead. Policies are inherited from abstract parents, so a soft-delete guard can live on a base type: ```asl abstract type Soft { deleted_at: datetime; policy not_deleted for select using ( .deleted_at is null ); } type Note extends Soft { required body: str; } ``` ## The table owner bypasses RLS `ENABLE ROW LEVEL SECURITY` applies to ordinary roles, but the **table owner (and superusers) bypass it by default**. If your application connects as a non-owner role (the recommended setup), policies apply as written. If it connects as the owner, the policies are silently ignored — you'd need `FORCE ROW LEVEL SECURITY`, which axel does not emit today. This is usually what you want for a TTL/GC pattern: reads from the app role see only live rows, while a privileged cleanup job (e.g. a [pg_cron](/asl/extensions) sweep) still sees expired rows to delete them. ## Pairing with a one-time setup: `@for` The cleanup job above is registered once with the `@for ` function directive — a function that axel **invokes a single time** in the migration that first creates it (after the type's table exists), and tags to that type for tracking: ```asl use extension 'pg_cron'; @for KV function kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', 'DELETE FROM kv WHERE expires_at < now()'); }; ``` The swept SQL can also be written as AQL and compiled in place, so the job is checked against the schema — see [Inline AQL](/asl/functions#inline-aql-aql): ```asl @for KV function kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', aql`delete KV filter .expires_at < now()`); }; ``` emitting, in that migration: ```sql CREATE OR REPLACE FUNCTION "kv_gc"() RETURNS BIGINT AS $$ … $$ LANGUAGE plpgsql; SELECT "kv_gc"(); ``` Migrations are diffed by name and run in dependency order (extensions → tables → functions → policies), so the extension exists before the function and the table exists before its policy. --- Source: https://struckchure.github.io/axel/asl/globals # Globals A `global` is a request-scoped variable — the authenticated user, the active tenant, a timezone — that your queries and [policies](/asl/policies) can read without threading it through every call. It's declared at the top level: ```asl global current_user: uuid; # optional by default global required tenant: str; ``` - **Leading `global`**, then an optional **`required`** modifier, then `name: type`. Omitting `required` makes the global optional. - The type is any scalar (`uuid`, `str`, `int64`, …) — not an object type. Globals are **not DDL**: nothing is added to a migration for a `global` line. They are backed by a Postgres [session setting](https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-SET) (a custom GUC, `app.`). ## Reading a global Reference it in any AQL expression — a policy predicate, a query `filter`, a computed field — as `global `: ```asl type Doc { required owner: uuid; policy owner_only for all using ( .owner = global current_user ); } ``` It lowers to a read of the backing session setting: ```sql owner = current_setting('app.current_user', true)::UUID ``` **Optional vs required** maps to `current_setting`'s missing-ok flag: | Declaration | Lowering | When unset | | --------------------------- | ---------------------------------------------- | --------------- | | `global current_user: uuid` | `current_setting('app.current_user', true)` | `NULL` | | `global required tenant: str` | `current_setting('app.tenant', false)` | **raises** (fail-closed) | A required global that hasn't been set makes the query error rather than silently matching or returning nothing — the safe default for a tenant/isolation guard. ## Setting a global from the client `axel codegen` emits one `with` helper per global on the generated `Runner`. It opens a transaction, pushes the value into the session with `set_config('app.', $1, true)` (bound as a parameter — never interpolated — and transaction-local), then runs your queries against a client bound to that transaction. This is safe under connection pooling: the setting can't leak to the next borrower of the connection. ```ts await runner.withCurrentUser(userId, async (q) => { // every query here sees current_setting('app.current_user') = userId return q.listDocs({ /* … */ }); }); ``` ```go err := runner.WithCurrentUser(ctx, userID, func(q *generated.Queries) error { _, err := q.ListDocs(ctx, generated.ListDocsParams{ /* … */ }) return err }) ``` Because the setting is transaction-local, the queries you want it applied to must run **inside** the callback. ### Without the Runner The standalone query functions also accept globals directly — Go via functional options, TypeScript via a trailing options object. When any global is passed, the function runs its query inside a transaction that first applies the globals (compiled INSERTs carry their own `BEGIN/COMMIT`, which is stripped so it doesn't nest). With no options, the call is unchanged. ```go doc, err := generated.CreateDoc(ctx, db, params, generated.WithCurrentUser(userID)) ``` ```ts const doc = await createDoc(db, params, { currentUser: userId }); ``` ## The GUC namespace Globals live under the fixed `app.` prefix (`global current_user` → `app.current_user`). If you set the value yourself outside the generated helper, use the same name: ```sql SELECT set_config('app.current_user', '…uuid…', true); -- transaction-local ``` --- Source: https://struckchure.github.io/axel/asl/extensions # Extensions `use extension '';` enables a Postgres extension. It lowers to `CREATE EXTENSION IF NOT EXISTS`, emitted **before** tables and functions in a migration (and dropped last on the way down), so anything that depends on it — a function, a default, a column type — is created after it exists. ```asl use extension 'unaccent'; use extension 'uuid-ossp'; # quoted, so hyphenated names work ``` ```sql CREATE EXTENSION IF NOT EXISTS "unaccent"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; ``` Extensions are tracked in the migration snapshot: declaring one adds a `CREATE EXTENSION`, removing the declaration adds a `DROP EXTENSION` to the next migration, and an unchanged declaration produces no diff. Nothing is enabled automatically. In particular, `gen_uuid()` defaults compile to `gen_random_uuid()`, which requires `pgcrypto` — so declare it explicitly: ```asl use extension 'pgcrypto'; ``` A common pairing is an extension plus a function that uses it: ```asl use extension 'unaccent'; @language plpgsql @immutable function slugify(value: text) -> text { return lower(public.unaccent(value)); }; ``` ## Custom Extension Types Extensions like **PostGIS** (`postgis`), **pgvector** (`vector`), and **citext** introduce custom PostgreSQL data types. You can declare scalars that map directly to these extension types using `extends sql ""`, and define client-side typing with `as`: ```asl use extension 'postgis'; use extension 'vector'; use extension 'citext'; # 1. Structured record representation with deserialize and serialize receiver functions scalar type Point extends sql "geography(Point, 4326)" as { latitude: float32; longitude: float32; }; function (p Point) deserialize() Point { return Point{ latitude: ST_Y(p::geometry), longitude: ST_X(p::geometry) }; }; function (p Point) serialize() { return ST_SetSRID(ST_MakePoint(p.longitude, p.latitude), 4326); }; # 2. Multi-dimensional array representation (codegen emits number[] / []float32) scalar type Embedding extends sql "vector(1536)" as multi float32; # 3. Primitive scalar alias (codegen emits string) scalar type Citext extends sql "citext" as str; # 4. Opaque custom SQL type (defaults to 'str' in host codegen) scalar type Geometry extends sql "geometry"; type Venue { required id: uuid { constraint pk; }; name: Citext; location: Point; feature_vec: Embedding; geom: Geometry; } ``` ### Benefits: - **Exact DDL**: Generated migration SQL produces columns with the precise PostgreSQL type (e.g. `geography(Point, 4326)`, `vector(1536)`). - **Client Typing**: SDK generators produce typed interfaces/structs (`Point` as `{ latitude: number, longitude: number }`, `Embedding` as `number[]`). - **AQL Dot-Access & Sub-shapes**: Structured fields can be traversed directly in AQL expressions (`.location.latitude`) and projected with sub-shapes (`select Venue { location: { latitude, longitude } }`). - **Zero-Overhead SQL Inlining**: Queries automatically inline PostGIS conversions (`json_build_object` on reads, `ST_SetSRID` on writes). See [Functions](/asl/functions) for the full function syntax and [Aliases & Extended Types](/asl/data-types/aliases) for more on custom scalar types. --- Source: https://struckchure.github.io/axel/asl/splitting # Splitting a Schema A schema does not have to live in one file. Point `schema-path` at a glob, and Axel reads every matching `.asl` file and merges them into one schema: ```yaml # axel.yaml schema-path: schema/*.asl ``` ``` schema/ base.asl # abstract types shared by everything user.asl post.asl functions.asl ``` Every command that takes a schema accepts the same forms: | Form | Meaning | |--------------------|--------------------------------------------------| | `schema.asl` | a single file | | `schema/` | a directory, walked recursively for `.asl` files | | `schema/*.asl` | every `.asl` directly inside `schema/` | | `schema/**/*.asl` | every `.asl` under `schema/`, at any depth | ```sh axel validate --schema 'schema/*.asl' axel codegen --schema-path 'schema/**/*.asl' -g go -o ./gen ``` Quote the pattern so Axel expands it rather than your shell — that way `**` works the same everywhere. If you run `axel -d ./myproject` with no `axel.yaml`, a `schema/` directory in the project is picked up automatically once `schema.asl` and `default.asl` are absent. ## One flat namespace There is no `import` and no per-file scoping. The files are concatenated, so a declaration in one file is visible from all the others: ```asl # schema/base.asl abstract type Base { required id: uuid { default := gen_uuid(); constraint pk; }; } ``` ```asl # schema/post.asl type Post extends Base { required title: str; required link author: User; # User lives in schema/user.asl } ``` Order never matters — not within a file, and not between files. A type may extend a parent declared in a file that is read later, and a link may point at a type declared anywhere in the set. `use extension` is deduplicated, so each file is free to declare the extensions it depends on: ```asl use extension 'uuid-ossp'; ``` ## Duplicate names are an error Because the files share one namespace, declaring the same name twice is rejected, and the error names both locations: ``` resolving schema: type "User" declared more than once (schema/user.asl:3:1 and schema/billing.asl:14:1) ``` The same applies to enums, scalar aliases, globals and functions — and across kinds, so an enum and a type cannot share a name either. --- Source: https://struckchure.github.io/axel/aql # Axel Query Language (AQL) AQL is a query language that compiles to parameterized PostgreSQL SQL. You write queries in `.aql` files; Axel outputs SQL strings you execute with your language's standard driver. Axel never runs queries for you. :::tip[AOT Queries over Runtime Builders] Axel strongly encourages **Ahead-of-Time (AOT) query compilation**. Instead of constructing queries at runtime via dynamic query builder APIs, you write `.aql` files and compile them at build time with `axel codegen`. This validates query syntax against your schema, eliminates runtime query-building overhead, and generates exact, type-safe parameters and response models for your target language (Go, TypeScript). ::: ``` query.aql ``` ## How AQL is organized The reference is split by feature: - **[Parameters](/aql/parameters)** — named, optional, and typed query parameters. - **[Select](/aql/select)** — single vs multi, shapes, filters, ordering, nested links, and aggregates. - **[Insert](/aql/insert)** — inserting rows, links, and `unless conflict` upserts. - **[Update](/aql/update)** — updates and partial updates. - **[Delete](/aql/delete)** — deleting rows. - **[Expressions](/aql/expressions)** — operators, literals, path expressions, and casts. - **[With](/aql/with)** — bind a subquery once with `with (...)` and reuse it across the query. - **[Directives](/aql/directives)** — `@name` / `@request` / `@response` codegen metadata. - **[Grammar reference](/aql/grammar)** — the full AQL grammar. ## Output format Every compiled query produces: - A positional-parameter SQL string (`$1`, `$2`, ...) - A comment header mapping parameter names to positions ```sql -- $1: email (str) -- $2: active (bool) SELECT u.id AS id, u.email AS email FROM "user" u WHERE u.email = $1 AND u.active = $2; ``` The parameter order matches first-appearance order in the query. --- Source: https://struckchure.github.io/axel/aql/parameters/named # Named parameters Named parameters use a `$` prefix. They are collected in order of first appearance and mapped to positional `$N` SQL parameters. ```aql select User filter .email = $email and .active = $active; ``` ```sql -- $1: email -- $2: active SELECT ... FROM "user" u WHERE u.email = $1 AND u.active = $2; ``` --- Source: https://struckchure.github.io/axel/aql/parameters/optional # Optional parameters A trailing `?` marks a parameter optional (`$email?`). In a filter, an optional parameter is **skipped when its value is null** — the condition becomes a no-op — so a single query can support present/absent filters. The generated parameter type is nullable (Go `*T`, TypeScript `field?: T | null`). ```aql multi select User { id, email } filter .email = $email?; ``` The same optionality can be declared once in a [`var` block](/aql/parameters/typed) instead of at every use site — the two forms compile identically: ```aql var ( $email: str?; ) multi select User { id, email } filter .email = $email; ``` ```sql -- $1: email SELECT u.id AS id, u.email AS email FROM "user" u WHERE ($1 IS NULL OR u.email = $1); ``` Passing `null` for `email` returns all users; passing a value filters by it. ## In an `or` group The skip-when-null behavior above is the identity of an **`and`** context: an omitted param matches every row, so the surrounding conjunction is unaffected. Inside an **`or`**, that same "match-all" would satisfy the whole disjunction and silently void the other arms. So an omitted optional param in an `or` instead **drops out** of the group — the guard flips from `IS NULL OR` to `IS NOT NULL AND`: ```aql multi select Project filter .owner = $owner? or .organization = $org?; ``` ```sql -- $1: owner -- $2: organization SELECT ... FROM "project" p WHERE ($1::UUID IS NOT NULL AND p.owner = $1) OR ($2::TEXT IS NOT NULL AND p.organization = $2); ``` Each optional relaxes only its own comparison; the connective it sits in decides whether "omitted" means match-all (`and`) or drop-out (`or`). In a mixed expression the arms inside a parenthesized `or` group take the drop-out identity while a sibling optional filter outside the group keeps match-all. ## Inside a value subquery When a scalar subquery is used as a *value* — a link assignment or a `(select ...)` operand — an omitted optional param in its filter must yield **no row** (so the subquery evaluates to `NULL` and a `??` fallback can fire), rather than matching all rows and returning an arbitrary one. The value context forces the same `IS NOT NULL AND` guard: ```aql insert GithubInstallation { organization := (select Organization filter .id = $org?) ?? (select GithubInstallation filter .installation_id = $iid?).organization, installation_id := $iid }; ``` ```sql COALESCE( (SELECT o.id FROM "organization" o WHERE ($1::UUID IS NOT NULL AND o.id = $1) LIMIT 1), (SELECT g.organization FROM "github_installation" g WHERE ($2::BIGINT IS NOT NULL AND g.installation_id = $2) LIMIT 1)) ``` When `$org` is omitted the first lookup returns nothing, so the `??` chain falls through to the second. See [Insert basics](/aql/insert/basics) and [Updating links](/aql/update/links). ## With a default A parameter declared with a default is **coalesced, not skipped** — the comparison still runs, using the default when the value arrives null. Skipping it as well would silently ignore the default you asked for: ```aql var ( $age: int32? := 21; ) multi select User { id } filter .age >= $age; ``` ```sql WHERE u.age >= COALESCE($1::INTEGER, 21) ``` ## Optional array parameters An optional [`multi` parameter](/aql/parameters/typed#array-parameters-multi) is guarded the same way, and every cast of the placeholder stays the **array** type: ```aql var ( multi $emails: str?; ) multi select User { id } filter .email in $emails; ``` ```sql WHERE ($1::TEXT[] IS NULL OR u.email = ANY($1::TEXT[])) ``` Casting one placeholder to both `TEXT` and `TEXT[]` in the same statement would make Postgres reject it, so the array type wins everywhere. ## In an `update` `set` clause An optional parameter assigned directly to a column behaves differently again — `null` writes `NULL` to the column rather than being skipped. See [Partial updates](/aql/update/partial). --- Source: https://struckchure.github.io/axel/aql/parameters/typed # Typed parameters By default a parameter's type is **inferred** from the property it's compared against (`.email = $email` → `str`) or the column it's assigned to. Params with no such anchor — most commonly `limit` / `offset` — have no inferable type and would otherwise generate a loose `any` field. ## Top-level `var` declarations You can declare and type parameters at the top of your query using a `var (...)` block or individual `var` statements: ```aql var ( $status?; $limit?; $offset?; ) multi select Transaction { id } filter .status = $status order by .created_at desc limit $limit offset $offset; ``` Or as single `var` statements: ```aql var $status?; var $limit?; multi select Transaction { id } filter .status = $status limit $limit; ``` When declared with `var`, parameters can be referenced as bare names (`$status`, `$limit`) throughout filters, subqueries, and clauses without needing inline `` annotations repeated at every use site. ### `:type` and `` are the same A declaration may spell its type either way. Pick one and stay consistent within a query: ```aql var ( $status: TransactionStatus?; # same as $status? $limit := 20; # same as $limit: int32 := 20 ) ``` The optional `?` goes after the type, and a `:= default` after that. ## Array parameters (`multi`) Prefix a declaration with `multi` to bind a **single array value** rather than one element: ```aql var ( multi $ids: uuid; multi $roles: UserType; ) multi select User { id, email } filter .id in $ids and .role in $roles; ``` ```sql -- $1: ids (uuid) -- $2: roles (str) SELECT u.id AS id, u.email AS email FROM "user" u WHERE u.id = ANY($1::UUID[]) AND u.role = ANY($2::TEXT[]); ``` Membership against an array parameter lowers to `= ANY($n::T[])`, never `IN` — Postgres `IN` expects a parenthesised list and rejects an array bind. The parameter reaches the generated clients as an array type — **one argument, not a spread**: ```ts await queries.usersByIds({ ids: ["…", "…"], roles: [UserType.Admin] }); ``` ```go rows, err := queries.UsersByIds(ctx, gen.UsersByIdsParams{ Ids: []string{"…", "…"}, Roles: []gen.UserType{gen.UserTypeAdmin}, }) ``` If the declaration omits the type, the element type is inferred from the column the parameter is compared against: ```aql var ( multi $ages; ) multi select User { id } filter .age in $ages; # $ages inferred int32, → u.age = ANY($1) ``` An array parameter may also carry a default, which is what [bulk inserts](/aql/insert/bulk) iterate over: ```aql var multi $conditions: str? := {'Hot', 'Cold', 'Fragile'}; ``` Array parameters are distinct from `multi` **fields** in the schema, though they interact naturally: a `multi` scalar column is also compared with `= ANY`. See [Multi properties](/asl/fields/properties). ## Inline annotations Alternatively, annotate a parameter inline where it is used with `$name`. The annotation goes before any `?`: ```aql multi select Transaction { id } filter .status = $status order by .created_at desc limit $limit? offset $offset?; ``` The type may name any declared **value** type from your schema: - a **builtin scalar** — `str`, `int16`/`int32`/`int64`, `float32`/`float64`, `bool`, `uuid`, `datetime`, `date`, `time`, `json`, `bytes`, `decimal` - a **scalar alias** — e.g. `scalar type EmailStr extends str` renders as its base builtin - an **enum** — e.g. `TransactionStatus`, which generates the real enum type in code (Go `TransactionStatus`, TypeScript `TransactionStatus`) rather than a bare `string` Object types (tables) are **not** valid parameter types — a parameter is a value, not a row — and an unknown type name is a compile error. Annotations override inference, so an explicit annotation always wins. Even without one, an enum-backed column is inferred as its enum type: `filter .status = $status` types `$status` as `TransactionStatus` automatically. --- Source: https://struckchure.github.io/axel/aql/select/basics # Select basics ## Single vs multi A plain `select` returns a **single** row — Axel appends an implicit `LIMIT 1`, and code generation produces a single-row (`*Row`) result. Prefix the query with `multi` to return **all** matching rows (no implicit limit, a `[]Row` result). `limit`/`offset` are only allowed on a `multi select`. ```aql select User { id, email }; # one row → LIMIT 1 multi select User { id, email }; # all rows → no implicit limit ``` ## Basic select ```aql select User; ``` Selects all scalar properties of the type (a single row). ## Shape A shape limits which fields are returned. ```aql select User { id, email, name }; ``` ```sql SELECT u.id AS id, u.email AS email, u.name AS name FROM "user" u LIMIT 1; ``` --- Source: https://struckchure.github.io/axel/aql/select/filtering # Filtering ```aql select User { id, email } filter .active = true and .age >= $min_age; ``` ```sql -- $1: min_age SELECT u.id AS id, u.email AS email FROM "user" u WHERE u.active = true AND u.age >= $1 LIMIT 1; ``` See [Expressions](/aql/expressions) for the full set of operators and how conditions combine with `and` / `or`. --- Source: https://struckchure.github.io/axel/aql/select/ordering # Ordering & Pagination ## Order by ```aql select User { id, email } order by .created_at desc; ``` ```sql SELECT u.id AS id, u.email AS email FROM "user" u ORDER BY u.created_at DESC LIMIT 1; ``` Multiple fields: ```aql select User { id, email } order by .active desc, .created_at asc; ``` ## Limit and offset `limit`/`offset` require `multi select` (a plain select already returns a single row). ```aql multi select User { id, email } order by .created_at desc limit $limit offset $offset; ``` ```sql -- $1: limit -- $2: offset SELECT u.id AS id, u.email AS email FROM "user" u ORDER BY u.created_at DESC LIMIT $1 OFFSET $2; ``` ## Combining clauses ```aql multi select User { id, email, name } filter .active = true and .age >= $min_age order by .created_at desc limit $limit offset $offset; ``` --- Source: https://struckchure.github.io/axel/aql/select/computed # Computed shape fields A shape field can be assigned an inline expression using `:=`. A common use is a sub-select that pulls related data without defining a link in the schema. A sub-select follows the same cardinality rule as a top-level query: **plain `select` returns a single object** (or `null`), and **`multi select` returns a JSON array**. ```aql select User { id, email, posts := (multi select Post { id, title } filter .author.id = User.id), # array primary_org := (select Organization { id, name } filter .owner = User.id) # single object or null } ``` A `multi select` compiles to a correlated `json_agg` subquery (empty array — never null — when nothing matches); a plain `select` compiles to `row_to_json` over a `LIMIT 1` inner query (null when nothing matches). The outer type name (`User.id`) is a **qualified reference** — it resolves to the outer query's alias. ```sql (SELECT COALESCE(json_agg(row_to_json(p_posts_sub)), '[]') FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts, (SELECT row_to_json(o_primary_org_sub) FROM (SELECT o.id AS id, o.name AS name FROM "organization" o WHERE o.owner = u.id LIMIT 1) o_primary_org_sub) AS primary_org ``` Computed shape fields with no sub-select compile as scalar expressions (including arithmetic and null coalescing): ```aql select OrderItem { id, unit_price, quantity, discount, subtotal := .unit_price * .quantity, total := (.unit_price * .quantity) - .discount, label := .name ?? .sku } ``` ## Projecting a field from a subquery A subquery normally resolves to a row's id. Append `.field` to project a single column instead — the subquery then behaves as a scalar and can be combined with other operators. This works anywhere an expression is allowed, including `insert` / `update` assignment values: ```aql update Repo filter .id = $id set { installation_id := (select GithubInstallation filter .id = $installation_id?).installation_id ?? .installation_id } ``` ```sql UPDATE "repo" r SET installation_id = COALESCE( (SELECT g.installation_id FROM "github_installation" g WHERE ($1::UUID IS NULL OR g.id = $1) LIMIT 1), r.installation_id) WHERE r.id = $2 ``` The projected field must be a scalar property or a link on the subquery's type; an unknown field is a compile error. An optional `` after the projection casts the result (see [Casts & types](/aql/expressions/casts) — the cast works on any operand): ```aql installation_id := (select GithubInstallation filter .id = $id).installation_id ?? .installation_id ``` ```sql installation_id = COALESCE(((SELECT g.installation_id FROM ... LIMIT 1))::TEXT, r.installation_id) ``` > **Note:** field projection is available in generated queries (both Go and > TypeScript output, which share the compiler). The TypeScript runtime `aql` > tagged-template — for queries assembled dynamically at runtime — does not yet > parse projections or `??` in assignment values. --- Source: https://struckchure.github.io/axel/aql/select/nested # Nested shapes (links) Shapes can include linked types. Axel compiles nested shapes into a single SQL query using `row_to_json` or `json_agg` — no N+1. ## Single link Returns a JSON object. ```aql select Post { id, title, author: { id, email } }; ``` ```sql SELECT p.id AS id, p.title AS title, (SELECT row_to_json(u_author_sub) FROM ( SELECT u_author.id AS id, u_author.email AS email FROM "user" u_author WHERE u_author.id = p.author LIMIT 1 ) u_author_sub) AS author FROM "post" p; ``` ## Multi link Returns a JSON array. Empty results return `[]` rather than `null`. ```aql select Post { id, title, likes: { id, email } }; ``` ```sql SELECT p.id AS id, p.title AS title, (SELECT COALESCE(json_agg(row_to_json(u_likes_sub)), '[]') FROM ( SELECT u_likes.id AS id, u_likes.email AS email FROM "post_likes" jt_likes JOIN "user" u_likes ON u_likes.id = jt_likes.user WHERE jt_likes.post = p.id ) u_likes_sub) AS likes FROM "post" p; ``` ## Deeper nesting A link sub-shape is a full shape: it may itself select nested links, [computed fields](/aql/select/computed), and the `*` splat — to any depth. Each link nests another correlated JSON subquery inside its parent. ```aql select Application { id, project: { id, organization: { id, name } } }; ``` The same paths work in a `filter`: a multi-step path resolves through the intervening links down to the target column, so `.project.organization.id` filters against project's organization FK without an explicit join. ```aql multi select Application { *, project: { id, organization: { id } } } filter .project.organization.owner = $user and .project.organization.id = $organization?; ``` ## Loading strategies By default Axel compiles nested shapes using **correlated subqueries** in the `SELECT` projection (the `query` strategy). You can switch to **`LEFT JOIN LATERAL`** instead — either globally in `axel.yaml` or per-query with a directive. ### `query` — correlated subqueries (default) Each nested link becomes a correlated scalar subquery inside the `SELECT` list: - Single links → `row_to_json(...)` - Multi links → `COALESCE(json_agg(...), '[]')` Best for most workloads. Keeps the outer query simple and lets the planner evaluate each subquery only for the rows it needs. ### `join` — LEFT JOIN LATERAL Each nested link becomes a `LEFT JOIN LATERAL` in the `FROM` clause: ```aql @rel_load_strategy join select Post { id, title, author: { id, email }, likes: { id, email } }; ``` ```sql SELECT p.id, p.title, author.author, likes.likes FROM "post" p LEFT JOIN LATERAL ( SELECT row_to_json(u_sub) AS author FROM (SELECT id, email FROM "user" WHERE id = p.author LIMIT 1) u_sub ) author ON true LEFT JOIN LATERAL ( SELECT COALESCE(json_agg(row_to_json(u_sub)), '[]') AS likes FROM ( SELECT u.id, u.email FROM "post_likes" jt JOIN "user" u ON u.id = jt.user WHERE jt.post = p.id ) u_sub ) likes ON true; ``` Prefer `join` when the planner benefits from seeing all lateral joins together — for instance, when you filter or order by columns from nested relations, or when your PostgreSQL version handles lateral joins more efficiently for your data shape. ### Configuring the strategy **Per-query** — use the `@rel_load_strategy` directive at the top of the `.aql` file: ```aql @rel_load_strategy join ``` **Globally** — set it in `axel.yaml` so all queries in the project use it: ```yaml rel-load-strategy: join ``` The per-query directive takes precedence over the global setting. See [Directives](/aql/directives) for the full list of query-level options. --- Source: https://struckchure.github.io/axel/aql/select/aggregates # Aggregate select ## count ```aql select count(User); ``` ```sql SELECT COUNT(*) FROM ( SELECT 1 FROM "user" u ) _agg; ``` With a filter: ```aql select count(User filter .active = true); ``` ```sql SELECT COUNT(*) FROM ( SELECT 1 FROM "user" u WHERE u.active = true ) _agg; ``` ## As a scalar subquery An aggregate can also be wrapped in parentheses and used as a **scalar operand** anywhere an expression is accepted — inside a `filter`, or on the right-hand side of an `update` `set` assignment. It compiles to the same `SELECT COUNT(*)` wrapped in parentheses, and its `filter` may correlate to the outer row. ```aql multi select User { id, email } filter (select count(Post filter .author.id = User.id)) > 0; ``` ```sql SELECT u.id AS id, u.email AS email FROM "user" u WHERE (SELECT COUNT(*) FROM ( SELECT 1 FROM "post" p WHERE p.author = u.id ) _agg) > 0; ``` The inner `filter .author.id = User.id` references the outer alias (`u.id`), so the count is evaluated per user. The same form works as an assignment value — `set { has_posts := (select count(Post filter .author.id = User.id)) > 0 }`. > **Note:** an aggregate subquery is only valid as an expression operand. It is > not accepted as a [computed shape field](/aql/select/computed) value > (`{ n := (select count(...)) }`). ## Aggregate shape — many aggregates in one scan A **select whose shape fields are aggregates** computes several aggregates over the same set in a single pass. Each field is `name := (.column)` with an optional per-field `filter`, and the select's own `filter` (after the shape, as usual) is the shared condition applied to every field: ```aql select Transaction { success_debit := sum(.amount) filter .type = TransactionType.Debit and .status = TransactionStatus.Successful, pending_debit := sum(.amount) filter .type = TransactionType.Debit and .status = TransactionStatus.Pending, success_credit := sum(.amount) filter .type = TransactionType.Credit and .status = TransactionStatus.Successful, pending_credit := sum(.amount) filter .type = TransactionType.Credit and .status = TransactionStatus.Pending, } filter (.sender_id = $api_key_id and .sender_entity = TransactionActorEntity.ApiKey) or (.reciever_id = $api_key_id and .reciever_entity = TransactionActorEntity.ApiKey); ``` Each field lowers to a Postgres [`FILTER (WHERE …)`](https://www.postgresql.org/docs/current/sql-expressions.html#SYNTAX-AGGREGATES) aggregate, so the whole query is **one scan** — no correlated subqueries: ```sql SELECT SUM(t.amount) FILTER (WHERE t.type = 'Debit' AND t.status = 'Successful') AS success_debit, SUM(t.amount) FILTER (WHERE t.type = 'Debit' AND t.status = 'Pending') AS pending_debit, SUM(t.amount) FILTER (WHERE t.type = 'Credit' AND t.status = 'Successful') AS success_credit, SUM(t.amount) FILTER (WHERE t.type = 'Credit' AND t.status = 'Pending') AS pending_credit FROM "transaction" t WHERE (t.sender_id = $1 AND t.sender_entity = 'ApiKey') OR (t.reciever_id = $1 AND t.reciever_entity = 'ApiKey'); ``` The result is a **single row** (one `*Row` in generated code); `multi`, `order by`, `limit`, and `offset` are not allowed. ### Rules - Aggregate functions: `sum`, `avg`, `min`, `max`, `count`. `count()` (no argument) is `COUNT(*)`; the others take an argument expression (such as `.column`, or a math / function expression like `min(haversine(.loc.lat, .loc.lon, $target_lat, $target_lon))`). The per-field `filter` is optional. - A shape is an **aggregate shape** as soon as one field is an aggregate; **every** field must then be an aggregate — mixing aggregates with plain row fields requires a **[Group By clause](/aql/select/group-by)**. - **Result types.** Aggregate fields are nullable (an aggregate over zero rows is `NULL`). `count` is `int64`; `min`/`max` keep the column's type. `sum` and `avg` change type in Postgres (e.g. `sum` of a `bigint` column is `numeric`), so add a cast to pin the generated type — `sum(.amount)` — otherwise the field is typed as `any` and code generation warns. --- Source: https://struckchure.github.io/axel/aql/select/group-by # Group By & Having AQL supports grouped aggregation queries using `group by` and `having` clauses on `select` and `multi select` statements. ## Grouped select To group records and compute aggregates per group, specify a `group by` clause and select the grouping properties and aggregate fields in the shape: ```aql multi select Transaction { status, order_count := count(), total_volume := sum(.amount) } group by .status; ``` ```sql SELECT t.status AS status, COUNT(*) AS order_count, (SUM(t.amount))::BIGINT AS total_volume FROM "transaction" t GROUP BY t.status; ``` ## Filter (WHERE) and Having (HAVING) - `filter` filters individual rows **before** grouping (compiles to SQL `WHERE`). - `having` filters groups **after** aggregation (compiles to SQL `HAVING`). ```aql multi select Transaction { status, total_volume := sum(.amount), successful_volume := sum(.amount) filter .status = TransactionStatus.Successful, order_count := count() } filter .created_at >= $since group by .status having count() >= $min_orders and sum(.amount) > $min_volume order by total_volume desc limit $limit; ``` ```sql -- $1: since (datetime) -- $2: min_orders (int64) -- $3: min_volume (int64) -- $4: limit (int32) SELECT t.status AS status, (SUM(t.amount))::BIGINT AS total_volume, (SUM(t.amount) FILTER (WHERE t.status = 'Successful'))::BIGINT AS successful_volume, COUNT(*) AS order_count FROM "transaction" t WHERE t.created_at >= $1 GROUP BY t.status HAVING COUNT(*) >= $2 AND SUM(t.amount) > $3 ORDER BY total_volume DESC LIMIT $4; ``` ## Multiple grouping columns You can group by multiple fields by separating them with commas: ```aql multi select Transaction { status, type, total := sum(.amount), count := count() } group by .status, .type; ``` ```sql SELECT t.status AS status, t.type AS type, (SUM(t.amount))::BIGINT AS total, COUNT(*) AS count FROM "transaction" t GROUP BY t.status, t.type; ``` ## Rules - **Shape requirements:** In a grouped select, every shape field must either be a grouping column, an aggregate expression (`count()`, `sum()`, `avg()`, `min()`, `max()`), or a computed expression over group columns and aggregates. Ungrouped non-aggregate columns are rejected with a compile error. - **No wildcard:** `*` splat is not permitted in a grouped query. - **Conditional aggregates:** Per-field `filter` (`FILTER (WHERE ...)`) is supported on aggregate fields in grouped queries. - **Single vs multi select:** `multi select` returns all groups (with optional `limit` and `offset`); `select` returns a single group (with implicit `LIMIT 1`). --- Source: https://struckchure.github.io/axel/aql/insert/basics # Insert ```aql insert User { email := $email, name := $name, age := $age }; ``` ```sql -- $1: email -- $2: name -- $3: age INSERT INTO "user" ("email", "name", "age") VALUES ($1, $2, $3) RETURNING *; ``` ## Inserting with a link Assign a link by passing a subquery that resolves to the FK value. ```aql insert Post { title := $title, author := (select User filter .email = $email) }; ``` ```sql -- $1: title -- $2: email INSERT INTO "post" ("title", "author") VALUES ($1, (SELECT u.id FROM "user" u WHERE u.email = $2 LIMIT 1)) RETURNING *; ``` > The SQL samples on these pages write `RETURNING *` for brevity. Axel actually emits the explicit > column list of the inserted row — `RETURNING "id", "title", "author"` for the query above — which is > what the generated row type is built from. A link assignment accepts any scalar expression that resolves to the FK value, not just a solo subquery: - **A bare parameter** — pass the FK directly; a lone link param infers `uuid`. ```aql insert Post { title := $title, author := $author_id }; ``` - **A subquery projection** — select a *linked* FK column rather than the row id with `(select …).link`. ```aql insert GithubInstallation { organization := (select GithubInstallation filter .installation_id = $iid).organization, installation_id := $iid }; ``` - **A `??` chain** — coalesce several lookups; the FK resolves from whichever finds a row first. ```aql insert GithubInstallation { organization := (select Organization filter .id = $org?) ?? (select GithubInstallation filter .installation_id = $iid?).organization, installation_id := $iid }; ``` See [Optional parameters — value subquery](/aql/parameters/optional) for how an omitted param lets the chain fall through. - **A sub-insert** — create the linked row inline; it lowers to a CTE. See [Conflicts](/aql/insert/conflicts) for the (unsupported) interaction with `unless conflict` on sub-inserts. ```aql insert Post { title := $title, author := (insert User { email := $email, name := $name }) }; ``` To handle a uniqueness collision, see [Conflicts](/aql/insert/conflicts). To reassign a link on an existing row, see [Updating links](/aql/update/links). --- Source: https://struckchure.github.io/axel/aql/insert/conflicts # Handling conflicts (`unless conflict`) An `insert` may declare what to do when it collides with an existing row on a unique (`exclusive`) or primary-key constraint. This lowers to Postgres `ON CONFLICT`. **Do nothing on any conflict:** ```aql insert User { email := $email, name := $name } unless conflict; ``` ```sql INSERT INTO "user" ("email", "name") VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *; ``` **Do nothing on a specific constraint:** ```aql insert User { email := $email, name := $name } unless conflict on .email; ``` ```sql INSERT INTO "user" ("email", "name") VALUES ($1, $2) ON CONFLICT ("email") DO NOTHING RETURNING *; ``` Use `on (.a, .b)` to target a composite `exclusive` constraint. **Upsert — update the existing row on conflict (`else`):** ```aql insert User { email := $email, name := $name } unless conflict on .email else (update User set { name := $name }); ``` ```sql INSERT INTO "user" ("email", "name") VALUES ($1, $2) ON CONFLICT ("email") DO UPDATE SET "name" = $2 RETURNING *; ``` Rules and behavior: - The `on` target must be backed by an `exclusive` or primary-key constraint; otherwise compilation fails. - `else` requires an `on` target, its type must match the insert's type, and it takes no `filter` (Postgres targets the conflicting row automatically). - **`RETURNING` behavior differs by form:** `DO UPDATE` returns the updated row, but `DO NOTHING` returns **no row** when a conflict occurs. Handle the empty result in calling code for the `unless conflict` / `unless conflict on ...` forms. - The clause is supported on top-level inserts only (not nested `(insert ...)` link sub-inserts). --- Source: https://struckchure.github.io/axel/aql/insert/bulk # Bulk Insert AQL supports bulk insertions using EdgeQL-style `for ... in ...` iteration statements combined with multi-valued parameters or set literals. ```aql var multi $conditions: str? := {'Hot', 'Cold', 'Fragile', 'Frozen'} for $condition in $conditions { insert PackageCondition { name := $condition, added_by := (select User filter .email = 'alice@example.com') } unless conflict; } ``` ```sql -- $1: conditions (str[]) WITH __for_iter AS ( SELECT unnest(COALESCE($1::TEXT[], ARRAY['Hot', 'Cold', 'Fragile', 'Frozen']::TEXT[])) AS "condition" ) INSERT INTO "package_condition" ("name", "added_by") SELECT __for_iter."condition", (SELECT u.id FROM "user" u WHERE u.email = 'alice@example.com' LIMIT 1) FROM __for_iter ON CONFLICT DO NOTHING RETURNING "id", "name", "added_by"; ``` --- ## How It Works 1. **`var multi $param: type? := default`**: - `multi` specifies that the parameter expects an array of elements (e.g. `TEXT[]`, `UUID[]`, `INT[]`). - `: type` annotates the element scalar type. - `:=` assigns an optional default array expression (e.g. a set literal `{'A', 'B'}`). 2. **`for $item in $collection { ... }`**: - Iterates through each element in `$collection` (which can be a parameter or an inline set literal). - In the loop body, `$item` can be referenced in field assignments or subqueries. - The loop body compiles to a PostgreSQL Common Table Expression (CTE) using `unnest(...)`, followed by `INSERT ... SELECT ... FROM __for_iter`. --- ## Examples ### Bulk Inserting with Set Literals You can iterate over inline set literals directly: ```aql for $role in {'Admin', 'Editor', 'Viewer'} { insert Role { name := $role } unless conflict; } ``` ### Bulk Insert with Related Subqueries and Upserts Each row in the loop can execute correlated lookups and handle uniqueness conflicts: ```aql var multi $tags: str? for $tag in $tags { insert Tag { name := $tag, created_by := (select User filter .id = $user_id) } unless conflict on .name else ( update Tag set { usage_count := .usage_count + 1 } ); } ``` ### Upserting Iterator Values (`EXCLUDED`) Inside `ON CONFLICT ... DO UPDATE`, Postgres has only two rows in scope: the existing row and `EXCLUDED`, the row the insert proposed. The `__for_iter` CTE is *not* in scope there. When the `else` update reuses an expression the insert already writes, Axel rewrites it to that column's `EXCLUDED` reference: ```aql var multi $plans: str? := {'A1|0|NGN|512|256'} for $plan in $plans { insert Plan { name := split_part($plan, '|', 1), price := split_part($plan, '|', 2), currency := split_part($plan, '|', 3), memory := split_part($plan, '|', 4), cpu := split_part($plan, '|', 5) } unless conflict on .name else (update Plan set { price := split_part($plan, '|', 2), memory := split_part($plan, '|', 4) }); } ``` ```sql -- $1: plans (str[]) WITH __for_iter AS ( SELECT unnest(COALESCE($1::TEXT[], ARRAY['A1|0|NGN|512|256']::TEXT[])) AS "plan" ) INSERT INTO "plan" ("name", "price", "currency", "memory", "cpu") SELECT split_part(__for_iter."plan", '|', 1), (split_part(__for_iter."plan", '|', 2))::BIGINT, split_part(__for_iter."plan", '|', 3), (split_part(__for_iter."plan", '|', 4))::INTEGER, (split_part(__for_iter."plan", '|', 5))::INTEGER FROM __for_iter ON CONFLICT ("name") DO UPDATE SET "price" = EXCLUDED."price", "memory" = EXCLUDED."memory" RETURNING "cpu", "currency", "id", "memory", "name", "price"; ``` Only values the insert actually writes are available this way. An `else` update that derives a new value from the iterator — a field the insert never assigns, for example — is rejected at compile time instead of failing at run time with `missing FROM-clause entry for table "__for_iter"`. :::note `split_part` field positions are 1-based in Postgres; position `0` raises `field position must be greater than zero` at run time. ::: --- Source: https://struckchure.github.io/axel/aql/update/basics # Update ```aql update User filter .id = $id set { name := $name, active := $active }; ``` ```sql -- $1: name -- $2: active -- $3: id UPDATE "user" u SET name = $1, active = $2 WHERE u.id = $3 RETURNING *; ``` To leave columns unchanged when a value is absent, see [Partial updates](/aql/update/partial). --- Source: https://struckchure.github.io/axel/aql/update/partial # Partial updates An optional parameter (`$name?`) in a `set` clause is plain nullable: when the value is `null`, the column is **written to `NULL`**. (This differs from an optional parameter in a *filter*, where `null` skips the condition — see [Optional parameters](/aql/parameters/optional).) To leave a column **unchanged** when a value is absent, coalesce the parameter to the column's current value with `?? .field`: ```aql update Application filter .id = $id set { status := $status?, # null → sets the column to NULL build_system := $build_system? ?? .build_system # null → keeps the current value }; ``` ```sql -- $1: status -- $2: build_system -- $3: id UPDATE "application" a SET status = $1, build_system = COALESCE($2::TEXT, a.build_system) WHERE a.id = $3 RETURNING *; ``` The `??` cast (`$2::TEXT` here) is the column's SQL type, so the parameter's type is determinable even when its value is `null`. --- Source: https://struckchure.github.io/axel/aql/update/links # Updating links A single link can be reassigned in a `set` clause. The right-hand side is any scalar expression that resolves to the target's FK value — the same forms accepted when [inserting a link](/aql/insert/basics). ## From a subquery ```aql update Application filter .id = $id set { installation := (select GithubInstallation filter .installation_id = $iid) }; ``` ```sql -- $1: iid -- $2: id UPDATE "application" a SET installation = (SELECT g.id FROM "github_installation" g WHERE g.installation_id = $1 LIMIT 1) WHERE a.id = $2 RETURNING *; ``` ## From a parameter Pass the FK value directly. A bare link param infers `uuid`. ```aql update Application filter .id = $id set { owner := $owner }; ``` ```sql -- $1: owner -- $2: id UPDATE "application" a SET owner = $1 WHERE a.id = $2 RETURNING *; ``` ## Keeping the current link Coalesce the lookup with the link's own column (`?? .link`) to leave the FK unchanged when the subquery finds nothing. Make the lookup param optional so an omitted value produces no row and the fallback fires — see [Optional parameters — value subquery](/aql/parameters/optional). ```aql update Application filter .id = $id set { installation := (select GithubInstallation filter .installation_id = $iid?) ?? .installation }; ``` ```sql -- $1: iid -- $2: id UPDATE "application" a SET installation = COALESCE( (SELECT g.id FROM "github_installation" g WHERE ($1::BIGINT IS NOT NULL AND g.installation_id = $1) LIMIT 1), a.installation) WHERE a.id = $2 RETURNING *; ``` The `.installation` fallback resolves to the current row's FK column, so an omitted `$iid` keeps the existing link instead of matching an arbitrary installation. A subquery projection may be coalesced the same way — `(select … ).installation_id ?? .installation_id` selects the linked FK column rather than the row id, and an optional cast (`.field`) applies to the projected value. --- ## Multi-links Many-to-many relationships (`multi link members: User`) can be modified using either delta assignments (`{ "+": ..., "-": ... }`) or full set replacement. ### Delta modification (`+` and `-`) Use `"+"` to add items and `"-"` to remove items from a multi-link: ```aql update Organization filter .id = $id set { members := { "+": (multi select User filter .email in $invite_emails), "-": (select User filter .id = $removed_user_id) } }; ``` This compiles to a clean CTE pipeline that applies the deletions and insertions on the underlying junction table: ```sql WITH _target AS ( SELECT o.* FROM "organization" o WHERE o.id = $1 ), _del_members AS ( DELETE FROM "organization_members" WHERE "organization" IN (SELECT id FROM _target) AND "user" IN (SELECT u.id FROM "user" u WHERE u.id = $2) ), _ins_members AS ( INSERT INTO "organization_members" ("organization", "user") SELECT _target.id, _sub.id FROM _target CROSS JOIN (SELECT u.id FROM "user" u WHERE u.email IN (...)) AS _sub(id) ON CONFLICT DO NOTHING ) SELECT o.id, o.name FROM _target o; ``` - Removals (`"-"`) always execute before additions (`"+"`). - Either `"+"` or `"-"` or both can be provided. - Keys can be written as `"+"` / `"-"`, `'+'` / `'-'`, or bare `+` / `-`. ### Delta assignment is multi-**link** only `multi` appears in two different declarations that look alike: ```asl multi link members: User; # a junction table → delta assignment applies multi roles: UserType; # an array column → delta assignment does not ``` Delta assignment is a junction-table operation — it emits `INSERT`/`DELETE` against the link table — so it has nothing to act on for a `multi` scalar, which is a single array column on the row. Writing one produces: ``` delta assignment requires a multi link; "roles" is a multi scalar (assign the whole array instead) ``` Assign a `multi` scalar as a whole value instead: ```aql update User filter .id = $id set { roles := $roles }; ``` Membership against a `multi` scalar is likewise an array test rather than a junction lookup — see [Multi scalars](/asl/fields/links#multi-scalars-are-not-links). ### Full replacement Assigning an expression directly to a multi-link reconciles the relation by replacing all existing links with the new set: ```aql update Organization filter .id = $id set { members := (multi select User filter .department = 'Engineering') }; ``` ```sql WITH _target AS ( SELECT o.* FROM "organization" o WHERE o.id = $1 ), _del_members AS ( DELETE FROM "organization_members" WHERE "organization" IN (SELECT id FROM _target) ), _ins_members AS ( INSERT INTO "organization_members" ("organization", "user") SELECT _target.id, _sub.id FROM _target CROSS JOIN (SELECT u.id FROM "user" u WHERE u.department = 'Engineering') AS _sub(id) ON CONFLICT DO NOTHING ) SELECT o.id, o.name FROM _target o; ``` --- Source: https://struckchure.github.io/axel/aql/delete # Delete ```aql delete User filter .id = $id; ``` ```sql -- $1: id DELETE FROM "user" u WHERE u.id = $1; ``` --- Source: https://struckchure.github.io/axel/aql/with # With A `with (...)` block binds named subqueries for the statement that follows. Each binding lowers to a Postgres CTE, so a sub-select used at several points in a filter is evaluated once instead of being inlined per use site. ```aql with ( business := (select Business filter .id = $business_id); api_keys := (multi select ApiKey filter .business = $business_id); ) multi select Transaction filter ( business is not null and ( .sender_id = business.id or .sender_id in api_keys.id or .reciever_id in api_keys.id ) ) order by .updated_at desc limit $limit? offset $offset?; ``` A block may precede any statement — `select`, `insert`, `update`, or `delete`. ## Single-row and set bindings The `multi` keyword decides what a binding is, exactly as it does on a select: - `name := (select T ...)` binds a **single row**. It is capped at one row, and referencing it yields a value. - `name := (multi select T ...)` binds a **set** of rows. It is not capped, and it is only usable on the right of `in`. Using a set binding as a value is rejected at compile time, rather than becoming a `more than one row returned by a subquery` failure at run time: ```aql # error: binding "api_keys" is a `multi select` (a set, not a value) filter .sender_id = api_keys.id ``` ## Referring to a binding A binding is referenced by name, in two forms: | Form | Meaning | | --- | --- | | `business` | the bound row's `id` | | `business.id`, `business.name` | that column of the bound row | The bare form is what makes an existence test read naturally: ```aql filter business is not null ``` A binding **shadows a type or enum of the same name** for the whole statement, so `Business.id` inside the query below reads the binding, not the type: ```aql with (Business := (select Business filter .slug = $slug)) multi select User filter .business = Business.id; ``` Lowercase binding names avoid the ambiguity entirely and are the recommended style. ## Narrowing the projection with `{ shape }` By default a binding projects every scalar column and single-link FK column of its type into the CTE. When only a subset of fields will be used at the reference sites, you can list them explicitly with a `{ shape }`: ```aql with ( api_key := ( multi select ApiKey { id } filter .id = $api_key_id? or .business.id = $business_id? ) ) multi select Transaction filter .sender_id in api_key.id or .reciever_id in api_key.id; ``` Only the named fields are projected into the CTE. Referencing a field that was not included in the shape is caught at compile time: ```aql # error: field "label" was not included in the { shape } filter .name = api_key.label ``` Use `*` to keep the full projection explicit without restricting it: ```aql api_key := (multi select ApiKey { * } filter ...) ``` ### Casting on a set reference When the column type in the CTE does not match the column you are comparing against, you can attach a `` directly to the field reference on the right of `in`. The cast is applied inside the subquery projection: ```aql # api_key.id is uuid; sender_id is stored as text — cast at the reference site filter .sender_id in api_key.id or .reciever_id in api_key.id ``` This avoids having to rewrite the binding itself or add a computed column. ## Restrictions `limit` / `offset` require a `multi` binding, matching the rule for a plain `select`. Aggregates cannot be bound; use them directly in the query. A `with` block is also not available inside a trigger or function body, which has no host statement to carry the CTE. Nested sub-shapes and computed (`:=`) fields inside a binding shape are not supported — the shape may only name scalar properties and single-link FK columns. ## Formatting `axel fmt` keeps each binding on its own line. When a binding subquery doesn't fit on a single line, it is wrapped in indented parens with each clause on its own line — the same treatment a top-level select gets: ```aql with ( api_key := ( multi select ApiKey { id } filter .id = $api_key_id? or .business.id = $business_id? ) ) ``` Short bindings that fit within 80 columns are left on a single line: ```aql with (business := (select Business filter .id = $business_id)) ``` Boolean filters are broken across lines only when the single-line form exceeds 80 columns. The formatter never adds or removes parentheses, so the grouping you write is the grouping you keep. --- Source: https://struckchure.github.io/axel/aql/expressions/operators # Operators | AQL operator | SQL equivalent | | ------------ | -------------- | | `+` | `+` (addition or unary plus) | | `-` | `-` (subtraction or unary minus) | | `*` | `*` (multiplication) | | `/` | `/` (division) | | `=` | `=` | | `!=` | `!=` | | `<` | `<` | | `<=` | `<=` | | `>` | `>` | | `>=` | `>=` | | `and` | `AND` | | `or` | `OR` | | `??` | `COALESCE` | | `in` | `IN` | | `like` | `LIKE` | | `ilike` | `ILIKE` | | `is null` | `IS NULL` | | `is not null` | `IS NOT NULL` | ## Arithmetic AQL supports binary arithmetic operators (`+`, `-`, `*`, `/`) and unary signs (`+`, `-`). Expressions follow standard mathematical operator precedence: 1. **Unary** `+`, `-` (e.g. `- .discount`) 2. **Multiplicative** `*`, `/` 3. **Additive** `+`, `-` 4. **Comparisons & Null tests** `=`, `!=`, `<`, `<=`, `>`, `>=`, `is [not] null`, `??` 5. **Logical** `and`, then `or` ```aql # Computed shape fields select Order { id, subtotal, tax := .subtotal * 0.2, total := (.subtotal * 1.2) - .discount }; # Filtering with arithmetic multi select Product { id, title } filter .quantity * .unit_price >= $min_total - $rebate; # Updating with arithmetic update Account filter .id = $id set { balance := .balance - $amount }; # Ordering by calculated expressions multi select Product { id, name } order by .unit_price * .stock_count desc; ``` ## Null tests `is null` / `is not null` are postfix operators — they test the operand on their left and take no right-hand side: ```aql multi select Doc { id } filter .deleted_at is null; multi select Doc { id } filter .published_at is not null; ``` This is distinct from `??` (coalesce), which substitutes a fallback value rather than testing for presence. ## Combining conditions Conditions chain with `and` / `or` to any length. As in SQL, **`and` binds tighter than `or`**, so `a or b and c` means `a or (b and c)`. ```aql multi select Project { *, members: { id } } filter .owner = $owner and .organization = $organization order by .created_at desc; ``` Parenthesize to group conditions explicitly. Groups nest to any depth: ```aql multi select Post { id, title } filter (.title like $q or .content like $q) and (.published = true or .author = $viewer) and .deleted = false; ``` An [optional parameter](/aql/parameters/optional) inside a chain relaxes **only its own condition** — the rest of the filter still applies. Here, omitting `$author` widens the search to every author, but never returns an unpublished post: ```aql multi select Post { id } filter .published = true and .author = $author?; ``` --- Source: https://struckchure.github.io/axel/aql/expressions/literals # Literals | Value | Example | | ------- | --------------- | | String | `'hello'` | | Integer | `42` | | Float | `3.14` | | Boolean | `true`, `false` | | Null | `null` | --- Source: https://struckchure.github.io/axel/aql/expressions/paths # Path expressions Paths starting with `.` refer to fields on the current type. The compiler resolves them to `alias.column`. ```aql filter .active = true and .age >= $min_age order by .created_at desc ``` Multi-step paths traverse a link — and chain across several — resolving to a correlated subquery per hop: ```aql filter .author.email = $email filter .installation.installation_id = $iid ``` An invalid path (a step that resolves to no property or link) is a **compile error**. For how a path's type is resolved, see [Casts & types](/aql/expressions/casts). --- Source: https://struckchure.github.io/axel/aql/expressions/casts # Computed field types The type of a computed shape field is resolved in this order: 1. **Explicit cast** — a `` annotation always wins. 2. **Inferred** — a plain path is typed by resolving it through the schema: a path ending on a property takes that property's type; one ending on a link takes its FK type (`uuid`). 3. **`any`** — anything else (a coalesce, function call, arithmetic, or a path that can't be resolved to a scalar) is typed as `any` (`json`), and codegen prints a warning suggesting a cast. So the common case needs no annotation: ```aql multi select Application { *, owner := .project.organization.owner.id, # inferred uuid iid := .installation.installation_id # inferred int64 } ``` A `` cast may be appended to **any operand** — a path (`.a.b`), a parenthesized expression (`(.name ?? .email)`), a subquery projection (`(select …).slug`), or a bare literal (`'{}'`). It uses the same type names as [parameter annotations](/aql/parameters/typed), emits `()::TYPE`, and overrides inference / gives a type to an otherwise-uninferable field: ```aql secrets := '{}' # a JSON literal default who := (.name ?? .email) # otherwise: warning + typed as any ``` An invalid path (a step that resolves to no property or link) is a **compile error**, not a warning. --- Source: https://struckchure.github.io/axel/aql/directives # Directives A query file may begin with `@ ` declarations. Directives are real AQL syntax (parsed into the AST), not comments, and they carry code-generation metadata: ```aql @name CreateUser @request CreateUserInput @response User insert User { email := $email, name := $name }; ``` | Directive | Effect | |-----------|--------| | `@name ` | Sets the generated query/function name (default: derived from the filename) | | `@request ` | Names the generated params type (default: `Params`) | | `@response ` | Names the generated row type (default: `Row`) | | `@rel_load_strategy ` | Sets the relation loading strategy (`query` or `join`) for this query | ### Formatting Directives When formatting AQL files (via `axel fmt` or language server formatters), a blank line is automatically placed after the directive declarations block before the query statement. ### Relation Loading Strategies By default, nested relations and shapes are compiled using correlated subqueries in the `SELECT` projection (`query` strategy). You can override this globally in `axel.yaml` via `rel-load-strategy` or per-query with `@rel_load_strategy`: - **`query` (default)**: Uses correlated subqueries with `json_agg` (multi-links) and `row_to_json` (single-links) in the `SELECT` projection. - **`join`**: Uses `LEFT JOIN LATERAL` subqueries in the SQL `FROM` clause. ```aql @name GetUserWithPosts @rel_load_strategy join select User { id, email, posts: { id, title } } filter .id = $id; ``` Unknown directives are parsed and preserved (and exposed to external generators) but otherwise ignored. A `@response`/`@request` name may be **shared** across queries: the type is generated once and reused. If two queries give the same name but different fields — or a name collides with an existing schema type of a different shape — code generation **aborts** with a conflict error. (`@name` replaces the older `# @name` comment, which is no longer recognized.) --- Source: https://struckchure.github.io/axel/aql/grammar # Grammar reference The semicolon at the end of each statement is optional when the query is passed as an inline string. ``` Statement = VarBlock* WithBlock? (SelectStmt | InsertStmt | UpdateStmt | DeleteStmt) VarBlock = "var" "(" (Param ";")* ")" | "var" Param ";" Param = "$" Ident ("<" Ident ">")? "?"? WithBlock = "with" "(" (WithBinding ";")* ")" WithBinding = Ident ":=" "(" "multi"? "select" SelectBody ")" SelectStmt = "select" SelectBody ";"? SelectBody = AggExpr | TypeName Shape? Filter? GroupBy? Having? OrderBy? Limit? Offset? AggExpr = Ident "(" TypeName Filter? ")" InsertStmt = "insert" TypeName "{" Assignment ("," Assignment)* ","? "}" Conflict? ";"? UpdateStmt = "update" TypeName Filter? "set" "{" Assignment ("," Assignment)* ","? "}" ";"? DeleteStmt = "delete" TypeName Filter? ";"? Conflict = "unless" "conflict" ("on" ConflictTarget)? ("else" "(" ConflictUpdate ")")? ConflictTarget = "." Ident | "(" "." Ident ("," "." Ident)* ")" ConflictUpdate = "update" TypeName "set" "{" Assignment ("," Assignment)* ","? "}" Shape = "{" ShapeField ("," ShapeField)* ","? "}" ShapeField = Ident (":" Shape)? # leaf or nested link shape | Ident ":=" Expr Filter? # computed or aggregate field (with optional per-field filter) Assignment = Ident ":=" Expr Filter = "filter" Expr GroupBy = "group" "by" Expr ("," Expr)* Having = "having" Expr OrderBy = "order" "by" OrderItem ("," OrderItem)* OrderItem = Expr ("asc" | "desc")? Limit = "limit" Expr Offset = "offset" Expr Expr = AndExpr ("or" AndExpr)* # `and` binds tighter than `or` AndExpr = Cmp ("and" Cmp)* Cmp = AddExpr (BinOp AddExpr | "is" "not"? "null")? BinOp = "=" | "!=" | "<" | "<=" | ">" | ">=" | "??" | "in" | "like" | "ilike" AddExpr = MulExpr (("+" | "-") MulExpr)* MulExpr = Factor (("*" | "/") Factor)* Factor = ("+" | "-")? Primary Primary = Operand ("<" Ident ">")? # optional trailing cast on any operand Operand = "(" "multi"? "select" SelectBody ")" ("." Ident)? # sub-select (multi → array, else single); optional field projection | "(" "insert" TypeName "{" ... ")" # sub-insert returning id | "(" Expr ")" | FuncCall | PathExpr | QualifiedIdent # TypeName.field — outer-query reference | "$" Ident # named parameter | "global" Ident # global variable reference | "null" | "true" | "false" | String | Int | Float | Ident FuncCall = Ident "(" (Expr ("," Expr)*)? ")" PathExpr = ("." Ident)+ QualifiedIdent = Ident "." Ident # e.g. User.id in a sub-select filter ``` --- Source: https://struckchure.github.io/axel/examples # Examples Short, copy-pasteable recipes for things you'll reach for often. Each one is a small slice of ASL and/or AQL — every snippet here compiles as-is. | Recipe | What it shows | | ------ | ------------- | | [Audit timestamps & UUID keys](/examples/timestamps) | A reusable `Base` type: UUID primary keys and auto-touched `created_at` / `updated_at`. | | [Soft deletes](/examples/soft-delete) | Hide "deleted" rows with a policy instead of removing them. | | [Slugs from titles](/examples/slugs) | Derive a URL slug on insert/update with a function + rewrite. | | [Multi-tenant row ownership](/examples/multi-tenancy) | Scope every row to the current user with a global + RLS policy. | | [Expiring rows + cleanup](/examples/expiring-rows) | A TTL policy that hides stale rows, swept by a scheduled job. | | [Upserts](/examples/upsert) | Insert-or-update in one statement with `unless conflict`. | | [Nested data in one query](/examples/nested-data) | Fetch an object and its related rows as JSON — no N+1. | Most recipes build on a shared `Base` type (see [Audit timestamps & UUID keys](/examples/timestamps)); the examples show only the fields relevant to each recipe. New to Axel? Start with the [Tutorial](/tutorial), then come back here for task-focused patterns. --- Source: https://struckchure.github.io/axel/examples/timestamps # Audit timestamps & UUID keys A `Base` abstract type gives every table a UUID primary key, a `created_at` set once on insert, and an `updated_at` that touches itself on every update. Types that `extend Base` inherit all three. ```asl abstract type Base { required id: uuid { default := gen_uuid(); constraint exclusive; constraint pk; }; required created_at: datetime { default := datetime_current(); }; required updated_at: datetime { default := datetime_current(); rewrite update := datetime_current(); }; } type Article extends Base { required title: str; } ``` - `gen_uuid()` / `datetime_current()` are Axel builtins that lower to `gen_random_uuid()` (via [pgcrypto](/asl/extensions)) and `now()`. - The `rewrite update := datetime_current()` on `updated_at` becomes a `BEFORE UPDATE` trigger, so the column is stamped in the database — you never set it from application code. See [Rewrites](/asl/fields/rewrites). `Article` inherits the columns and the trigger; a plain insert only needs `title`: ```aql insert Article { title := $title }; ``` ## From generated code Save that query as `create_article.aql` and [`axel codegen`](/codegen) produces a typed `createArticle` function (and a `runner.query.createArticle` method). The returned row carries the DB-populated `id`, `createdAt`, and `updatedAt`: ```ts const article = await runner.query.createArticle({ title: "Hello" }); // article.id, article.createdAt, article.updatedAt are set by the database ``` ```go article, err := runner.Query.CreateArticle(ctx, gen.CreateArticleParams{Title: "Hello"}) // article.ID, article.CreatedAt, article.UpdatedAt are set by the database ``` Because `Base` is `abstract`, it produces no table of its own — it's a mixin you add to any concrete type ([Inheritance](/asl/schema/inheritance)). --- Source: https://struckchure.github.io/axel/examples/soft-delete # Soft deletes Instead of physically deleting a row, stamp a `deleted_at` and hide it from reads. A `Soft` mixin carries the column and a [policy](/asl/policies) that filters deleted rows out of every `SELECT`: ```asl abstract type Soft { deleted_at: datetime; policy not_deleted for select using ( .deleted_at is null ); } type Article extends Base, Soft { required title: str; } ``` "Delete" is an update that sets the timestamp: ```aql update Article filter .id = $id set { deleted_at := now() }; ``` Reads through the app role only ever see live rows — the policy appends `WHERE deleted_at IS NULL` for you: ```aql multi select Article { id, title }; ``` Counting explicitly (e.g. from a privileged role that bypasses RLS) still works with an `is null` filter: ```aql select count(Article filter .deleted_at is null); ``` ## From generated code Save the three queries as `soft_delete_article.aql`, `list_articles.aql`, and `count_live_articles.aql`: ```ts await runner.query.softDeleteArticle({ id }); // stamps deleted_at const live = await runner.query.listArticles(); // ListArticlesRow[] — deleted rows hidden by the policy const n = await runner.query.countLiveArticles(); // number ``` ```go _, err := runner.Query.SoftDeleteArticle(ctx, gen.SoftDeleteArticleParams{ID: id}) live, err := runner.Query.ListArticles(ctx) // []ListArticlesRow n, err := runner.Query.CountLiveArticles(ctx) // int64 ``` :::tip[The policy applies to ordinary roles; the **table owner bypasses RLS**, so a] privileged cleanup job can still see and purge soft-deleted rows. See [Policies → the table owner bypasses RLS](/asl/policies). ::: --- Source: https://struckchure.github.io/axel/examples/slugs # Slugs from titles Generate a URL-safe slug from a title in the database, so it's always in sync and never set by hand. A `slugify` [function](/asl/functions) does the transform; a [rewrite](/asl/fields/rewrites) applies it on insert and update. ```asl use extension 'unaccent'; @language plpgsql function slugify(value: text) -> text { return regexp_replace(lower(public.unaccent(value)), '[^a-z0-9]+', '-', 'gi'); }; type Article extends Base { required title: str; slug: str { constraint exclusive; rewrite create, update := slugify(__new__.title); }; } ``` - `rewrite create, update := slugify(__new__.title)` folds into a `BEFORE INSERT` and `BEFORE UPDATE` trigger. `__new__` is the row being written, so the slug is recomputed whenever the title changes. - `constraint exclusive` makes the slug unique. - [`unaccent`](/asl/extensions) folds accented characters (`Crème` → `creme`). Inserting only needs the title — the slug is filled in by the trigger: ```aql insert Article { title := $title }; ``` ## From generated code Saved as `create_article.aql`, the query becomes a typed function. The slug comes back on the returned row — you never compute it in application code: ```ts const article = await runner.query.createArticle({ title: "Crème Brûlée" }); // article.slug === "creme-brulee" ``` ```go article, err := runner.Query.CreateArticle(ctx, gen.CreateArticleParams{Title: "Crème Brûlée"}) // *article.Slug == "creme-brulee" ``` --- Source: https://struckchure.github.io/axel/examples/multi-tenancy # Multi-tenant row ownership Give each row an `owner` and let PostgreSQL enforce that users only see and write their own rows. The authenticated id flows in through a [global](/asl/globals), and a [policy](/asl/policies) compares it to `.owner`: ```asl global current_user: uuid; type Article extends Base { required title: str; required owner: uuid; policy owner_writes for all to app_user using ( .owner = global current_user ) with check ( .owner = global current_user ); } ``` This lowers to a `CREATE POLICY` that reads the current user from a session setting: ```sql CREATE POLICY "owner_writes" ON "article" FOR ALL TO app_user USING (owner = current_setting('app.current_user', true)::UUID) WITH CHECK (owner = current_setting('app.current_user', true)::UUID); ``` - `using` filters which existing rows are visible to `SELECT`/`UPDATE`/`DELETE`. - `with check` rejects inserts/updates that would set `owner` to anyone else. - Your app must connect as a **non-owner role** (here `app_user`) for the policy to apply — the table owner bypasses RLS. ## Setting the current user from the client `axel codegen` emits helpers that push the id into the session before running your queries (inside a transaction, so it's safe under connection pooling): ```ts // scope a block of queries through the Runner await runner.withCurrentUser(userId, async (q) => { return q.listArticles(); }); // or a single standalone call const article = await createArticle(db, params, { currentUser: userId }); ``` ```go // scope a block of queries through the Runner err := runner.WithCurrentUser(ctx, userID, func(q *gen.Queries) error { _, err := q.ListArticles(ctx) return err }) // or a single standalone call article, err := gen.CreateArticle(ctx, db, params, gen.WithCurrentUser(userID)) ``` With the setting in place, `list Articles` returns only the caller's rows, and an insert whose `owner` isn't the current user is rejected. See [Globals](/asl/globals) for optional vs `required` semantics. --- Source: https://struckchure.github.io/axel/examples/expiring-rows # Expiring rows + cleanup Give rows a time-to-live: hide them from reads once they expire, and delete them on a schedule. A [policy](/asl/policies) filters expired rows out of `SELECT`; a [`@for`](/asl/functions) function registers a [pg_cron](/asl/extensions) job to purge them. ```asl use extension 'pg_cron'; type Session extends Base { required token: str { constraint exclusive; }; expires_at: datetime; policy hide_expired for select using ( .expires_at is null or .expires_at >= now() ); } @for Session function session_gc() -> int64 { return cron.schedule('session-gc', '*/5 * * * *', aql`delete Session filter .expires_at < now()`); }; ``` - The policy makes a query see only rows that are **not yet expired** (or have no expiry) — the app never has to add a `WHERE expires_at >= now()`. - The sweep is written as [inline AQL](/asl/functions#inline-aql-aql): it compiles to `DELETE FROM "session" … ` while the migration is generated, so a rename in the type is caught at build time rather than silently breaking the cron job. - `@for Session` marks `session_gc` as a run-once setup function: Axel invokes it a single time (`SELECT session_gc();`) in the migration that first creates it, scheduling the cron job. See [Functions → `@for`](/asl/functions). - The cleanup job connects as a privileged role and bypasses RLS, so it can see and delete the expired rows the app can't. Reading is just a normal select — expired sessions are already invisible: ```aql select Session { id, token } filter .token = $token; ``` ## From generated code Saved as `get_session.aql`, the lookup returns the row or `null` — and an expired session reads as `null` because the policy has already filtered it out: ```ts const session = await runner.query.getSession({ token }); // GetSessionRow | null if (!session) throw new Error("invalid or expired session"); ``` ```go session, err := runner.Query.GetSession(ctx, gen.GetSessionParams{Token: token}) // *GetSessionRow if session == nil { return errors.New("invalid or expired session") } ``` --- Source: https://struckchure.github.io/axel/examples/append-only-log # Append-only event log An audit or event table should only ever grow: rows are inserted, never changed or removed. One [policy](/asl/policies) with a multi-command `for` clause locks both `UPDATE` and `DELETE` for the application role. ```asl enum EventKind { Created, Updated, Deleted } type Event { required topic: str; required kind: EventKind; payload: json; required actor: str; required at: datetime { default := now() }; # A DELETE has no "new row" to check, so block both writes with `using (false)` # — no existing row is ever visible to UPDATE or DELETE. policy append_only for update, delete using ( false ); } ``` Because Postgres allows one command per `CREATE POLICY`, the `for update, delete` list expands to two policies (suffixed to keep their names unique): ```sql ALTER TABLE "event" ENABLE ROW LEVEL SECURITY; CREATE POLICY "append_only_update" ON "event" FOR UPDATE USING (false); CREATE POLICY "append_only_delete" ON "event" FOR DELETE USING (false); ``` Inserts still work; updates and deletes from the app role affect zero rows. ```aql insert Event { topic := $topic, kind := EventKind.Created, actor := $actor }; ``` :::caution[`with check` vs `using`] It's tempting to write `for update, delete with check ( false )`, but Postgres rejects `WITH CHECK` on `DELETE` (there's no new row to validate). Axel catches this at `axel validate` / `axel diff` — before it hits the database — and points you at `using ( false )`, which blocks both by making existing rows invisible to the write. See [Policies → which clause each command accepts](/asl/policies#syntax). ::: :::tip[The table owner bypasses RLS] As with any policy, a privileged connection (the table owner or a superuser) bypasses RLS, so a maintenance job can still prune old events. Connect your application as a **non-owner role** for the guard to apply. See [Policies → the table owner bypasses RLS](/asl/policies). This is also why hosted Postgres providers like [Supabase](/integrations/supabase) lean on RLS: the app connects as a restricted role while the service role bypasses it. ::: --- Source: https://struckchure.github.io/axel/examples/job-queue # Job queue with a single claim A worker queue often needs an invariant like *"at most one **pending** job per (name, actor)"* — while still allowing many finished rows with the same key. A [partial unique constraint](/asl/schema/constraints#partial-filtered-unique-constraints) expresses exactly that: uniqueness that only applies to rows matching a filter. ```asl enum QueueStatus { Pending, Running, Failed, Processed } type Job { required name: str; required actor: str; payload: json; status: QueueStatus { default := QueueStatus.Pending }; claimed_at: datetime; processed_at: datetime; # Uniqueness only among Pending rows — Running/Failed/Processed are unconstrained. constraint exclusive on (.name, .actor) filter .status = QueueStatus.Pending; } ``` Postgres can't attach a `WHERE` to a table `UNIQUE`, so this lowers to a **partial unique index**: ```sql CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_name_actor" ON "job" ("name", "actor") WHERE (status = 'Pending'); ``` ## Enqueue is guarded A second `Pending` job for the same `(name, actor)` now raises a unique-violation at insert time — the database refuses the duplicate: ```aql insert Job { name := $name, actor := $actor, payload := $payload }; ``` Catch that error in your worker and treat it as "already queued". :::caution[`unless conflict` and partial indexes] `unless conflict on (.name, .actor)` won't silently absorb the duplicate here: Postgres can only infer a **partial** index as the `ON CONFLICT` arbiter when the conflict clause repeats the index predicate, which the `unless conflict` form doesn't emit. Rely on the raised error instead, or use a full (non-partial) `constraint exclusive on (.name, .actor)` if you want [upsert](/examples/upsert) semantics across every status. ::: ## Claiming a job frees the key Moving a job off `Pending` (to `Running`) drops it out of the partial index — so the same key can be enqueued again while the first one is still in flight: ```aql update Job filter .id = $id set { status := QueueStatus.Running, claimed_at := now() }; ``` ## From generated code ```ts await runner.query.enqueueJob({ name, actor, payload }); // no-op if a Pending twin exists await runner.query.claimJob({ id }); // status → Running ``` ```go _, err := runner.Query.EnqueueJob(ctx, gen.EnqueueJobParams{Name: name, Actor: actor, Payload: payload}) _, err = runner.Query.ClaimJob(ctx, gen.ClaimJobParams{ID: id}) ``` :::tip[The enum value in the filter (`QueueStatus.Pending`) is compiled to its stored] text (`'Pending'`) — see [Enums](/asl/data-types/enums). Enum members also autocomplete in your editor after `EnumName.`; see [Editor setup](/editors). ::: --- Source: https://struckchure.github.io/axel/examples/upsert # Upserts Insert a row, or update it if it already exists — in one round trip. AQL's `unless conflict` clause lowers to PostgreSQL's `ON CONFLICT` ([Insert → Conflicts](/aql/insert/conflicts)). ## Update on conflict ```aql insert User { email := $email } unless conflict on .email else (update User set { email := $email }); ``` compiles to: ```sql INSERT INTO "user" ("email") VALUES ($1) ON CONFLICT ("email") DO UPDATE SET "email" = $1 RETURNING "created_at", "email", "id", "role", "updated_at"; ``` The conflict target `.email` must be a unique/exclusive column: ```asl type User extends Base { required email: str { constraint exclusive; }; } ``` ## Ignore on conflict Drop the `else` arm to make a conflicting insert a no-op (`DO NOTHING`) — handy for idempotent seeds: ```aql insert User { email := $email } unless conflict; ``` ## From generated code Save the upsert as `upsert_user.aql`; the returned row is the inserted-or-updated `User`: ```ts const user = await runner.query.upsertUser({ email }); // UpsertUserRow | null ``` ```go user, err := runner.Query.UpsertUser(ctx, gen.UpsertUserParams{Email: email}) // *UpsertUserRow ``` --- Source: https://struckchure.github.io/axel/examples/nested-data # Nested data in one query Fetch a row together with its related rows in a single query. A nested [shape](/aql/select/nested) compiles to a `json_agg` lateral subquery, so PostgreSQL returns the parent and its children as one JSON structure — no second round trip, no N+1. ```aql multi select User { id, email, articles := (multi select Article { id, title } filter .owner = User.id) }; ``` compiles to: ```sql SELECT u.id AS id, u.email AS email, (SELECT COALESCE(json_agg(row_to_json(a_articles_sub)), '[]') FROM (SELECT a.id AS id, a.title AS title FROM "article" a WHERE a.owner = u.id) a_articles_sub) AS articles FROM "user" u; ``` - The inner `multi select` yields a JSON **array** (`[]` when empty); drop `multi` for a single related object instead. - `User.id` refers to the outer row — that's how the subquery correlates children to their parent. ## From generated code Saved as `list_users_with_articles.aql`, the query decodes straight into nested types — no manual joining, no second query: ```ts const users = await runner.query.listUsersWithArticles(); // users[0].articles is already ListUsersWithArticlesRowArticles[] for (const u of users) { console.log(u.email, u.articles.length); } ``` ```go users, err := runner.Query.ListUsersWithArticles(ctx) // users[0].Articles is []ListUsersWithArticlesRowArticles for _, u := range users { fmt.Println(u.Email, len(u.Articles)) } ``` See [Codegen](/codegen) and [Nested shapes](/aql/select/nested). --- Source: https://struckchure.github.io/axel/integrations # Integrations Axel sits between **standard PostgreSQL** and your application. On the database side, anything that speaks the Postgres wire protocol works — including managed providers like [Supabase](/integrations/supabase) and [Neon](/integrations/neon). On the application side, Axel [generates a typed client](/codegen) for [TypeScript](/integrations/typescript) and [Go](/integrations/golang) from your `.aql` queries. There's no provider-specific adapter: you point Axel's connection string at the database and run the usual [`axel diff` / `axel up`](/cli) flow. ## Connecting Axel reads the connection string from, in order of precedence: 1. the `--url` / `-u` flag, 2. the `database-url` key in `axel.yaml`, 3. the `AXEL_DATABASE_URL` env var, then `DATABASE_URL`. Config values support `$env.NAME` references, so keep the secret out of the file: ```yaml # axel.yaml schema-path: ./schema.asl migrations-dir: ./migrations database-url: $env.DATABASE_URL ``` ```sh export DATABASE_URL='postgresql://user:pass@host:5432/dbname?sslmode=require' axel up ``` :::tip[Direct vs. pooled connections] Migrations run **DDL** (`CREATE TABLE`, `ALTER TABLE`, `CREATE POLICY`, …). Point `axel diff` / `axel up` at a **direct / session-mode** connection, not a transaction-mode pooler (PgBouncer in transaction mode doesn't keep session state across statements). Both Supabase and Neon expose a direct endpoint alongside their pooler — use it for migrations. Your application's runtime queries can still use the pooled endpoint. ::: :::tip[SSL] Managed providers require TLS. Append `?sslmode=require` to the connection string if the provider's copy-paste URL doesn't already include it. ::: ## Row-level security travels well Both Supabase and Neon are ordinary Postgres, so Axel's [policies](/asl/policies) lower to the same `CREATE POLICY` + `ENABLE ROW LEVEL SECURITY` everywhere. Supabase in particular builds its client authorization on RLS — see the [append-only log](/examples/append-only-log) and [multi-tenant ownership](/examples/multi-tenancy) examples for patterns that map directly onto it. ## Database providers - [Supabase](/integrations/supabase) - [Neon](/integrations/neon) ## Language clients - [TypeScript](/integrations/typescript) - [Go](/integrations/golang) --- Source: https://struckchure.github.io/axel/integrations/supabase # Supabase [Supabase](https://supabase.com) is managed Postgres with auth, storage, and an auto-generated API layered on top. Axel manages the **database schema** — types, migrations, and row-level security — and Supabase serves it. There's no special adapter: Axel connects with a normal Postgres URL. ## Get the connection string In the Supabase dashboard, open **Project Settings → Database → Connection string** and copy the `URI`. You'll see a few variants: - **Direct connection** (`db..supabase.co:5432`) — a plain session connection. **Use this for Axel migrations.** - **Session pooler** (port `5432`, `...pooler.supabase.com`) — a good direct substitute on IPv4-only networks; also fine for migrations. - **Transaction pooler** (port `6543`) — for high-concurrency app runtime, **not** for migrations (no session state across statements). The URL already carries the database password; keep it in an env var. ```sh export DATABASE_URL='postgresql://postgres:@db..supabase.co:5432/postgres?sslmode=require' ``` ```yaml # axel.yaml schema-path: ./schema.asl migrations-dir: ./migrations database-url: $env.DATABASE_URL ``` ## Apply your schema ```sh axel validate # parse + type-check, no DB needed axel diff -n init # write a migration from the schema diff axel up # apply pending migrations ``` `axel up` tracks applied migrations in an `_axel_migrations` table, so re-running is safe. ## Row-level security fits naturally Supabase gates its client APIs on Postgres **RLS**, and Axel [policies](/asl/policies) lower straight to `CREATE POLICY` + `ENABLE ROW LEVEL SECURITY`. Supabase connects clients through restricted roles (`anon`, `authenticated`) while the `service_role` bypasses RLS — the same owner-bypass model Axel documents. A tenant-scoped example keyed on the current user. Axel reads the current user through a [`global`](/asl/globals), which lowers to a Postgres session setting (`current_setting('app.current_user', …)`): ```asl global current_user: uuid; type Document { required owner: uuid; required title: str; policy owner_rw for all to authenticated using ( .owner = global current_user ) with check ( .owner = global current_user ); } ``` Populate that session variable per request from the authenticated user id (Supabase exposes it as `auth.uid()`), e.g. `SET app.current_user = ''` at the start of the transaction. See [multi-tenant ownership](/examples/multi-tenancy) for the full pattern, and the [append-only log](/examples/append-only-log) for locking writes with a policy. :::caution[Migrate against a direct connection] Point `axel up` at the **direct** or **session pooler** connection. The transaction pooler (port `6543`) doesn't preserve session state between statements, which migrations rely on. ::: --- Source: https://struckchure.github.io/axel/integrations/neon # Neon [Neon](https://neon.tech) is serverless Postgres with branching and scale-to-zero. It's standard Postgres over the wire, so Axel connects with a normal connection URL and runs the usual [`axel diff` / `axel up`](/cli) flow. ## Get the connection string In the Neon console, open **Dashboard → Connection Details** and copy the connection string. Neon offers two endpoint styles: - **Direct** (`ep-..aws.neon.tech`) — **use this for Axel migrations.** - **Pooled** (`ep--pooler..aws.neon.tech`) — PgBouncer in transaction mode, for high-concurrency app runtime. Not for migrations. Neon **requires TLS**, so the URL includes `sslmode=require`: ```sh export DATABASE_URL='postgresql://:@ep-..aws.neon.tech/?sslmode=require' ``` ```yaml # axel.yaml schema-path: ./schema.asl migrations-dir: ./migrations database-url: $env.DATABASE_URL ``` ## Apply your schema ```sh axel validate # parse + type-check, no DB needed axel diff -n init # write a migration from the schema diff axel up # apply pending migrations ``` ## Branch-per-environment Neon's database branches pair well with Axel's file-based migrations: create a branch, point `DATABASE_URL` at its direct endpoint, and run `axel up` to bring that branch's schema up to date. Because migrations are tracked in `_axel_migrations`, each branch converges to the same schema independently. ```sh # preview branch export DATABASE_URL='postgresql://:@ep-preview-....aws.neon.tech/?sslmode=require' axel up ``` :::tip[Migrate against the direct endpoint] Run `axel up` against the **non-pooled** endpoint. The `-pooler` endpoint is transaction-mode PgBouncer and doesn't preserve session state between statements, which migrations rely on. ::: :::caution[Scale-to-zero cold starts] An idle Neon database suspends; the first migration statement may pause briefly while it wakes. This is expected — the command proceeds once the compute resumes. ::: --- Source: https://struckchure.github.io/axel/integrations/typescript # TypeScript A full walkthrough: scaffold a project, design a schema, apply migrations, then generate and use Axel's typed TypeScript client. See [Code Generation](/codegen) for the generator reference and [Schema Language](/asl/) for ASL details. ## 1. Scaffold the project ```sh axel init ``` This writes a starter project: ``` axel.yaml # config: schema-path, migrations-dir, database-url axel/schema.asl # a Base abstract type + a starter User axel/migrations/ # empty; migrations land here ``` `axel.yaml` points the database URL at an env var so secrets stay out of the file: ```yaml schema-path: axel/schema.asl migrations-dir: axel/migrations database-url: $env.DATABASE_URL ``` Set that to your Postgres — local, [Supabase](/integrations/supabase), or [Neon](/integrations/neon): ```sh export DATABASE_URL='postgresql://user:pass@localhost:5432/app?sslmode=disable' ``` ## 2. Design the schema The starter `axel/schema.asl` already defines a reusable `Base` (uuid primary key + `created_at` / `updated_at`) and a `User`. Add a `Post` linked to `User`: ```asl type Post extends Base { required title: str; content: str; required author: User; } ``` Type-check the schema at any time — no database needed: ```sh axel validate ``` ## 3. Diff and apply Generate a migration from the schema, then apply it to the database: ```sh axel diff -n init # writes axel/migrations/0001_init axel up # applies pending migrations ``` `axel up` records applied migrations in an `_axel_migrations` table, so it's safe to re-run. See the [CLI reference](/cli) for `diff` / `up` / `down`. ## 4. Write a query Queries live in `.aql` files. Create `queries/list_post.aql` and `queries/get_user.aql`: ```aql # list_post.aql multi select Post { id, title, content }; ``` ```aql # get_user.aql select User { id, name, email } filter .id = $id; ``` `multi select` returns many rows (`ListPostRow[]`); a plain `select` returns a single row (`GetUserRow | null`). ## 5. Generate the client ```sh axel codegen -g ts -o ./gen ``` Axel auto-discovers every `*.aql` file under the project directory and emits a `gen/` folder (`runner.ts`, `models.ts`, one file per query, and an `index.ts` barrel re-exporting all of them). A query's filename becomes a **camelCase** method: `list_post.aql` → `runner.query.listPost()`. (Or name files explicitly: `-q 'queries/*.aql'`.) The barrel lets you import from the output directory itself rather than reaching into individual files: ```ts ``` The default target is Bun's built-in `SQL`. For node-postgres, add `--option client=pg` and pass a `Pool` instead. ## 6. Connect and call ```ts const db = new SQL({ url: process.env.DATABASE_URL }); const runner = new Runner(db); const posts = await runner.query.listPost(); // ListPostRow[] const user = await runner.query.getUser({ id }); // GetUserRow | null ``` Params and rows are generated interfaces (`GetUserParams`, `ListPostRow`), with `datetime` mapped to `Date`, nullable columns to `T | null`, and camelCase field names (`createdAt`). ```ts const runner = new Runner(new Pool({ connectionString: process.env.DATABASE_URL })); ``` ### Transactions To run queries inside a transaction, use `runner.withDb(tx)`: ```ts // Direct call on transaction handle: const q = runner.withDb(tx); const user = await q.getUser({ id }); // Or scoped callback style: await runner.withDb(tx, async (q) => { const user = await q.createUser(userParams); return q.createPost({ ...postParams, authorId: user.id }); }); ``` ## 7. Ad-hoc queries with the builder Beyond the generated per-file methods, `runner.select()` and `runner.insert()` give you a **typed fluent builder** for queries you don't want to commit to a `.aql` file. The shape argument drives the inferred return type — no codegen step: ```ts const users = await runner .select("User", { id: true, email: true, name: true }) .where("active", "=", true) .and("age", ">=", 18) .or("email", "=", "admin@example.com") .all(); // users: Array<{ id: string; email: string; name: string | null }> ``` `.and()` / `.or()` are only available after `.where()`. Use `.all()` for many rows or `.one()` for a single row (`… | null`): ```ts const user = await runner .select("User", { id: true, email: true }) .where("id", "=", id) .one(); // { id: string; email: string } | null ``` Nest a builder as a shape value to pull related rows as a JSON array in one query. A backtick string is a **correlated reference** to the outer row: ```ts const authors = await runner .select("User", { id: true, name: true, posts: runner .select("Post", { title: true }) .where("authorId", "=", "`User.id`"), // outer-query reference }) .all(); ``` Select a **multi link** by naming it in the shape. It is fetched as a correlated JSON array over the link's junction table — no join or correlated filter to write by hand, and an empty relation comes back as `[]` rather than `null`: ```ts const vendors = await runner .select("Vendor", { id: true, name: true, members: true }) .all(); // vendors: Array<{ id: string; name: string; members: User[] }> ``` A multi link is a relation, not a column, so it is selectable but never insertable — `runner.insert("Vendor", …)` will not accept a `members` key. Use an AQL [delta assignment](/aql/update/links#multi-links) to modify the junction. Sub-shapes on a link (`{ members: { id: true } }`) are an AQL-only feature for now; the builder selects all of the target's columns. Insert with `runner.insert()`: ```ts const created = await runner .insert("User", { email: "alice@example.com", name: "Alice" }) .one(); ``` See [Code Generation → Fluent select builder](/codegen#fluent-select-builder-runner-select) for the complete builder API. ### Dynamic escape hatch For raw AQL that the builder can't express, `runner.run` executes a query string directly: ```ts const rows = await runner.run<{ id: string }>( `select Post { id } filter .author.id = $author`, { author }, ); ``` :::tip[Regenerate (`axel codegen -g ts -o ./gen`) whenever you change a query or the] schema, so the types stay in sync. At runtime the client only needs a Postgres connection string, so point it at your provider's pooled endpoint and keep [migrations](/cli) on the direct one. ::: --- Source: https://struckchure.github.io/axel/integrations/golang # Go A full walkthrough: scaffold a project, design a schema, apply migrations, then generate and use Axel's typed Go client. See [Code Generation](/codegen) for the generator reference and [Schema Language](/asl/) for ASL details. ## 1. Scaffold the project ```sh axel init ``` This writes a starter project: ``` axel.yaml # config: schema-path, migrations-dir, database-url axel/schema.asl # a Base abstract type + a starter User axel/migrations/ # empty; migrations land here ``` `axel.yaml` points the database URL at an env var so secrets stay out of the file: ```yaml schema-path: axel/schema.asl migrations-dir: axel/migrations database-url: $env.DATABASE_URL ``` Set that to your Postgres — local, [Supabase](/integrations/supabase), or [Neon](/integrations/neon): ```sh export DATABASE_URL='postgresql://user:pass@localhost:5432/app?sslmode=disable' ``` ## 2. Design the schema The starter `axel/schema.asl` already defines a reusable `Base` (uuid primary key + `created_at` / `updated_at`) and a `User`. Add a `Post` linked to `User`: ```asl type Post extends Base { required title: str; content: str; required author: User; } ``` Type-check the schema at any time — no database needed: ```sh axel validate ``` ## 3. Diff and apply Generate a migration from the schema, then apply it to the database: ```sh axel diff -n init # writes axel/migrations/0001_init axel up # applies pending migrations ``` `axel up` records applied migrations in an `_axel_migrations` table, so it's safe to re-run. See the [CLI reference](/cli) for `diff` / `up` / `down`. ## 4. Write a query Queries live in `.aql` files. Create `queries/list_post.aql` and `queries/get_user.aql`: ```aql # list_post.aql multi select Post { id, title, content }; ``` ```aql # get_user.aql select User { id, name, email } filter .id = $id; ``` `multi select` returns many rows (`[]ListPostRow`); a plain `select` returns a single row (`*GetUserRow`). ## 5. Generate the client ```sh axel codegen -g go -o ./gen --option package=gen ``` Axel auto-discovers every `*.aql` file under the project directory and emits a `gen/` package (`runner.go`, `models.go`, one file per query). A query's filename becomes a **PascalCase** method: `list_post.aql` → `runner.Query.ListPost(ctx)`. The package is named `generated` unless you override it with `--option package=...`. (Or name files explicitly: `-q 'queries/*.aql'`.) The generated package imports Axel's runtime, so add Axel and `pgx` to your module: ```sh go get github.com/struckchure/axel github.com/jackc/pgx/v5 ``` ## 6. Connect and call ```go package main "context" "log" "os" "github.com/jackc/pgx/v5/pgxpool" gen "github.com/you/app/gen" ) func main() { ctx := context.Background() db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } defer db.Close() runner := gen.NewRunner(db) posts, err := runner.Query.ListPost(ctx) // []ListPostRow user, err := runner.Query.GetUser(ctx, gen.GetUserParams{ID: id}) // *GetUserRow _ = posts _ = user } ``` Params and rows are generated structs (`GetUserParams`, `ListPostRow`) with `db` and `json` tags; `datetime` maps to `time.Time`, nullable columns to pointers (`*string`), and single-row queries return `*XxxRow`. ### Dynamic escape hatch For ad-hoc queries that aren't in a `.aql` file, `runner.Run` executes raw AQL: ```go rows, err := runner.Run(ctx, `select Post { id } filter .author.id = $author`, map[string]any{"author": author}, ) ``` ### Transactions To run queries inside a transaction, use `runner.WithDB(tx)` or `gen.NewQueries(tx)`: ```go tx, err := db.Begin(ctx) if err != nil { log.Fatal(err) } defer tx.Rollback(ctx) q := runner.WithDB(tx) // or: q := gen.NewQueries(tx) user, err := q.GetUser(ctx, gen.GetUserParams{ID: id}) if err != nil { return err } return tx.Commit(ctx) ``` :::tip[Regenerate (`axel codegen -g go -o ./gen`) whenever you change a query or the] schema, so the types stay in sync. At runtime the client only needs a Postgres connection string, so point it at your provider's pooled endpoint and keep [migrations](/cli) on the direct one. ::: --- Source: https://struckchure.github.io/axel/cli # Axel CLI Axel is invoked as `axel `. Commands split into two groups: - **Schema commands** — compile ASL and manage PostgreSQL migrations (require a DB connection) - **Query commands** — parse, compile, and generate code from AQL (no DB connection required) --- ## Global flags These flags are accepted by all commands. | Flag | Short | Description | |--------------------|-------|---------------------------------------------------------------------------------| | `--dir` | `-d` | Project directory — auto-discovers `axel.yaml`, `schema.asl`, `default.asl`, or `schema/` | | `--config` | `-c` | Explicit config file path (overrides `--dir`) | | `--url` | `-u` | PostgreSQL connection URL | | `--schema-path` | | Explicit schema: a file, a directory, or a glob such as `schema/*.asl` (overrides `--dir`) | | `--migrations-dir` | | Migrations directory (overrides `--dir`) | ### Project directory (`--dir`) The simplest way to configure Axel. Point it at your project folder and it figures out the rest: ```sh axel -d ./myproject validate axel -d ./myproject compile --aql 'select User { id, email };' axel -d ./myproject up axel -d ./myproject codegen -g go -o ./gen ``` Discovery order inside `--dir`: 1. `axel.yaml` — if found, loaded as the full config 2. `schema.asl` — used as the schema if no `axel.yaml` 3. `default.asl` — fallback schema name 4. `schema/` — a directory of `.asl` files, [merged into one schema](/asl/splitting) ### Config file (`--config`) For explicit control, or when the config lives outside the project directory: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/struckchure/axel/main/schema.json database-url: postgres://user:pass@localhost:5432/mydb schema-path: ./schema/main.asl # or a glob: ./schema/*.asl migrations-dir: ./migrations rel-load-strategy: query # query | join codegen: generator: go # go | ts out-dir: ./db/generated queries: - ./queries/*.aql options: package: generated ``` ```sh axel --config axel.yaml ``` --- ## Project initialization ### `axel init` Scaffolds a new Axel project by creating an `axel.yaml` config file, a starter `axel/schema.asl` schema with a `Base` type, and a `migrations` directory. ```sh axel init axel init --dir ./myproject --url postgres://localhost:5432/mydb ``` | Flag | Short | Description | |--------------------|-------|-------------------------------------------| | `--dir` | `-d` | Target directory to initialize in | | `--url` | `-u` | Database connection URL to put in config | | `--schema-path` | | Custom schema — file, directory, or glob (default: `axel/schema.asl`) | | `--migrations-dir` | | Custom migrations directory (default: `migrations`) | --- ## Schema commands ### `axel diff` Diffs the current `.asl` schema against the last migration and writes a new migration. ```sh axel diff --name "add users table" axel diff -n "add comments table" ``` > `axel generate` is a deprecated alias for `axel diff` and still works, printing a deprecation notice. | Flag | Short | Description | |----------|-------|------------------------------------------| | `--name` | `-n` | Human-readable label for the migration | The generated migration is written to `--migrations-dir` with a sequential version prefix: ``` migrations/ 0001/ up.sql down.sql metadata.json 0002/ ... ``` #### Required columns need a backfill `ALTER TABLE … ADD COLUMN x TEXT NOT NULL` fails on a table that already has rows. So when a `required` field with **no default** is added to an existing table — or an existing optional field is flipped to `required` — `axel diff` splits the change: the column is added nullable, a commented backfill seam is left in place, and `NOT NULL` is applied in a follow-up statement. The command also prints a warning naming the column. ```sql ALTER TABLE "vendor" ADD COLUMN "description" TEXT; -- axel: "description" is required and has no default. Existing rows need a value -- before the NOT NULL below can be applied: -- UPDATE "vendor" SET "description" = WHERE "description" IS NULL; ALTER TABLE "vendor" ALTER COLUMN "description" SET NOT NULL; ``` ``` warning: vendor.description is required with no default: existing rows must be backfilled in the migration before its SET NOT NULL succeeds ``` Replace `` with the backfill before running `axel up`. This is the one place a generated migration is *meant* to be edited — you are filling in a value Axel cannot know, not changing the DDL. Declaring a `default` on the field avoids the whole dance: Axel then knows the value, writes the `UPDATE` itself, and keeps `NOT NULL` inline. Required **links** get the same treatment, applied after their foreign key constraint is in place. --- ### `axel up` Applies all pending migrations in order. ```sh axel up axel --url postgres://... up ``` Axel tracks applied migrations in a `_axel_migrations` table it creates on first run. --- ### `axel down` Rolls back the last N migrations. ```sh axel down 1 # roll back the most recent migration axel down 3 # roll back the last 3 migrations ``` --- ### `axel status` Prints the state of all known migrations. ```sh axel status ``` Example output: ``` 0001_create_users applied 0002_add_posts applied 0003_add_comments pending ``` --- ## Query commands These commands work with AQL queries. They read the schema file but do not connect to a database. ### `axel validate` Parses and validates an ASL schema file. Exits with a non-zero status on errors. ```sh axel validate axel validate --schema axel/schema.asl ``` | Flag | Short | Default | Description | |------------|-------|-------------------|-------------------------| | `--schema` | `-s` | `axel/schema.asl` | The `.asl` schema — a file, a directory, or a glob such as `schema/*.asl` | On success: ``` schema "axel/schema.asl" is valid (5 types) ``` On failure (exits 1): ``` schema validation failed: • type "Post" extends unknown type "Taggable" • type "Comment" has a cycle in its inheritance chain ``` --- ### `axel compile` Compiles an AQL query (or all queries in a project) to parameterized SQL. #### Single-query mode ```sh # Inline query — use single quotes so the shell doesn't expand $params axel compile --aql 'select User { id, email } filter .id = $id' # From a file axel compile --file queries/get_users.aql # Write to a file axel compile --file queries/get_users.aql --out queries/get_users.sql ``` > **Shell quoting:** Always use single quotes around `--aql` values. Double quotes cause the shell to expand `$param` as a shell variable before Axel sees it. #### Warnings Unrecognised function names are passed straight through to the SQL — that pass-through is how arbitrary SQL and extension functions reach the output, so it cannot be an error. `axel compile` and `axel codegen` instead print a warning on **stderr**, and the [language server](/editors) shows it as a warning diagnostic: ``` warning: "distinct" is a SQL keyword, not a function; Postgres will reject distinct(...) ``` Warnings never stop compilation, which makes them easy to miss — but they mark exactly the queries that compile cleanly and then fail against a real database. #### Batch mode When `--dir` (`-d`) is supplied without `--aql` or `--file`, Axel finds all `*.aql` files under the project directory and compiles each one. ```sh # Compile everything in the project, write .sql files alongside the .aql files axel -d ./myproject compile # Write compiled .sql files to a separate directory (created automatically) axel -d ./myproject compile --output-dir ./sql ``` | Flag | Short | Default | Description | |-----------------|-------|-------------------|---------------------------------------------------------------------| | `--aql` | | | AQL query string (mutually exclusive with `--file`) | | `--file` | `-f` | | Path to a `.aql` file | | `--out` | `-o` | stdout | Output `.sql` file (single-query mode) | | `--output-dir` | | | Output directory for compiled `.sql` files (batch or single mode) | | `--schema-path` | | `axel/schema.asl` | Schema to compile against — file, directory, or glob | Example output: ```sql -- $1: active (bool) -- $2: min_age (int32) SELECT u.id AS id, u.email AS email FROM "user" u WHERE u.active = $1 AND u.age >= $2; ``` --- ### `axel run` Executes AQL queries directly against the connected PostgreSQL database and outputs the JSON results to stdout. ```sh # Inline query with parameters: axel run -c "multi select User { id, name } limit \$limit;" limit=20 # Query from a file with JSON parameter flag: axel run -f queries/get_users.aql -p '{"limit": 20}' # Using relaxed JSON or prefixed format: axel run queries/get_users.aql "params={skip: 1, limit: 20}" # Execute multiple AQL query files sequentially: axel run ./axel/seeds/*.aql # Execute multiple AQL query files concurrently: axel run --parallel ./axel/seeds/*.aql ``` | Flag | Short | Default | Description | |-----------------|-------|-------------------|---------------------------------------------------------------------| | `--command` | `-c` | | AQL query string to execute | | `--aql` | `-q` | | Alias for `--command` | | `--file` | `-f` | | Path to `.aql` file to execute | | `--params` | `-p` | | Query parameters in JSON, relaxed JSON, or key=value format | | `--format` | | `pretty` | Output format: `pretty` or `compact` | | `--parallel` | | `false` | Run multiple `.aql` files concurrently (default sequential) | | `--schema-path` | | `axel/schema.asl` | Schema to compile against | --- ### `axel repl` Starts an interactive REPL session for writing, compiling, and testing AQL queries against a database. ```sh # Start REPL in the current project axel repl # Specify schema and database URL axel repl --schema-path schema.asl --url postgres://localhost:5432/mydb # Start in table output format axel repl --format table ``` #### REPL Features - **Multi-line Query Input**: Automatically buffers multi-line queries until braces `{ ... }` or parentheses close, or when ending with `;`. - **Dual Mode**: Executes queries against PostgreSQL when connected; compiles and previews SQL when no database connection is active. - **Tab Completion**: Context-aware completion for AQL keywords, schema model names, field names, and meta-commands. - **Output Formats**: Switch between `pretty` (indented JSON), `table` (ASCII table), and `compact` JSON with `.format `. - **Persistent History**: Command history is saved across sessions to `~/.axel_history`. #### Meta-Commands | Command | Alias | Description | |---|---|---| | `.help` | `\?`, `\h` | Show help and command cheatsheet | | `.models` | `\dt` | List all models in the loaded schema | | `.schema [model]` | `\d` | View schema overview or examine a specific model/enum/scalar | | `.compile ` | `\c` | Compile an AQL query to SQL without executing against the database | | `.format [fmt]` | | Get or set output format (`pretty`, `table`, `compact`) | | `.param` | `\p` | List active query parameters | | `.param ` | | Set a session parameter (e.g. `.param limit 10`) | | `.param json ` | | Set parameters from JSON (e.g. `.param json {"skip": 5}`) | | `.param clear` | | Clear all active session parameters | | `.reload [path]` | `\r` | Reload schema from disk or load an alternative `.asl` file | | `.clear` | `\l` | Clear the terminal screen | | `.history` | | View recent command history | | `.exit`, `.quit` | `\q` | Exit the REPL (or press `Ctrl+D`) | | Flag | Short | Default | Description | |-----------------|-------|-------------------|---------------------------------------------------------------------| | `--format` | | `pretty` | Initial output format (`pretty`, `table`, `compact`) | | `--schema-path` | | `axel/schema.asl` | Schema file, directory, or glob to load | --- ### `axel fmt` Formats `.asl` schema and `.aql` query files in canonical style, preserving comments. Paths may be files or directories (searched recursively); with no paths, the current directory is formatted. Inside a type body, members are printed in blocks — properties and links (then computed fields), constraints, indexes, policies, triggers — with one blank line between blocks and none within one. Fields keep the order you wrote them in, since that decides column order; everything else is diffed by name, so grouping it costs nothing: ```asl type Post extends Base { required title: str; required link author: User; computed excerpt := .content; constraint exclusive on (.title, .author); index on (.title); policy owner_only for all using ( .author = global current_user ); } ``` ```sh # Print the formatted result to stdout axel fmt schema.asl # Rewrite files in place axel fmt -w . # CI check — exits non-zero and lists files that aren't formatted axel fmt --check . ``` | Flag | Short | Description | |-----------|-------|--------------------------------------------------------------------| | `--write` | `-w` | Write the result back to each file instead of stdout | | `--check` | | List files whose formatting differs and exit non-zero if any do | Formatting is safe: if a reformatted file would not parse back to the same structure, the original is left untouched. Invalid source is reported as an error. --- ### `axel codegen` Generates code from your schema and compiled AQL queries. See the [Code Generation](./codegen) guide for full details. ```sh # Go axel -d ./myproject codegen -g go -o ./gen # TypeScript axel -d ./myproject codegen -g ts -o ./gen # External generator binary axel -d ./myproject codegen --plugin ./my-generator -o ./gen ``` | Flag | Short | Default | Description | |-----------------|-------|---------|----------------------------------------------------------| | `--generator` | `-g` | | Built-in generator (`go` or `ts`) | | `--plugin` | `-p` | | Path to external generator binary | | `--out-dir` | `-o` | `.` | Directory to write generated files into | | `--query` | `-q` | | AQL file or glob pattern — repeatable | | `--schema-path` | | | Schema file, directory or glob (default: from config or `axel/schema.asl`) | | `--option` | | | `key=value` forwarded to the generator — repeatable | --- ## Typical workflow ```sh # 1. Write your schema vim schema.asl # 2. Validate it axel validate # 3. Generate and apply a migration axel diff -n "initial schema" axel up # 4. Write queries vim queries/list_posts.aql # 5. Generate typed code axel codegen -g go -o ./gen # 6. Compile queries to SQL (optional — codegen does this internally) axel compile --output-dir ./sql # 7. Use the generated code in your application ``` --- ## Environment variable `DATABASE_URL` is read as the default connection URL when `--url` and `--config` are not provided. ```sh export DATABASE_URL=postgres://user:pass@localhost:5432/mydb axel up ``` --- Source: https://struckchure.github.io/axel/aql/expressions # Expressions - **[Operators](/aql/expressions/operators)** — comparison / logical operators, `is null`, and combining conditions. - **[Literals](/aql/expressions/literals)** — string, number, boolean, and null literals. - **[Path Expressions](/aql/expressions/paths)** — `.field` paths and link traversal. - **[Casts & Types](/aql/expressions/casts)** — `` casts and computed-field type resolution. ## Global references `global ` reads a declared [global variable](/asl/globals) — the current user, active tenant, etc. It's an operand like a path or literal, so it works anywhere an expression does (a `filter`, a computed field, an RLS [policy](/asl/policies) predicate): ```aql multi select Doc { id, title } filter .owner = global current_user; ``` It lowers to a read of the backing session setting, `current_setting('app.', …)`. See [Globals](/asl/globals) for declaration and how the value is set from the client. --- Source: https://struckchure.github.io/axel/aql/insert # Insert - **[Basics](/aql/insert/basics)** — inserting rows and assigning links. - **[Conflicts](/aql/insert/conflicts)** — `unless conflict` (`ON CONFLICT`) and upserts. - **[Bulk Insert](/aql/insert/bulk)** — `for ... in ...` loops and multi-value insertions. --- Source: https://struckchure.github.io/axel/aql/parameters # Parameters Named parameters use a `$` prefix. They are collected in order of first appearance and mapped to positional `$N` SQL parameters. - **[Named](/aql/parameters/named)** — the basics of `$name` parameters. - **[Optional](/aql/parameters/optional)** — `$name?` and how it behaves in filters vs `set`. - **[Typed & Declarations](/aql/parameters/typed)** — top-level `var` declarations, `$name` annotations, and type inference. --- Source: https://struckchure.github.io/axel/aql/select # Select - **[Basics](/aql/select/basics)** — single vs multi, and shapes. - **[Filtering](/aql/select/filtering)** — the `filter` clause. - **[Ordering & Pagination](/aql/select/ordering)** — `order by`, `limit`, `offset`. - **[Computed Fields](/aql/select/computed)** — inline expressions and sub-selects in a shape. - **[Nested Shapes](/aql/select/nested)** — selecting linked types as JSON. - **[Aggregates](/aql/select/aggregates)** — `count` and friends. - **[Group By & Having](/aql/select/group-by)** — multi-row grouping and aggregate filtering. --- Source: https://struckchure.github.io/axel/aql/update # Update - **[Basics](/aql/update/basics)** — the `update ... set { ... }` statement. - **[Partial Updates](/aql/update/partial)** — optional params in `set`, and keeping current values. - **[Links](/aql/update/links)** — reassigning a link's FK, and keeping the current link. --- Source: https://struckchure.github.io/axel/asl/data-types # Data Types The value types a [property](/asl/fields/properties) or [parameter](/aql/parameters/typed) can hold. - **[Scalars](/asl/data-types/scalars)** — built-in scalar types and their PostgreSQL mappings. - **[Aliases](/asl/data-types/aliases)** — named aliases over a built-in scalar. - **[Enums](/asl/data-types/enums)** — enumerated string types with a `CHECK` constraint. --- Source: https://struckchure.github.io/axel/asl/fields # Fields Everything declared inside a type body that describes its data. For the schema-level declarations (indexes, composite constraints), see [Schema](/asl/schema). - **[Properties](/asl/fields/properties)** — scalar columns, `required`, and defaults. - **[Rewrites](/asl/fields/rewrites)** — auto-assign a field on insert/update. - **[Constraints](/asl/fields/constraints)** — field-level `exclusive`, `pk`, and `CHECK`s. - **[Links](/asl/fields/links)** — single and multi foreign-key relationships. - **[Computed Fields](/asl/fields/computed)** — derived values expanded during compilation. --- Source: https://struckchure.github.io/axel/asl/schema # Schema The building block of a schema is the **type**. This section covers types themselves and the schema-level declarations inside a type body. For the fields inside a type, see [Fields](/asl/fields). - **[Types](/asl/schema/types)** — concrete and abstract types. - **[Inheritance](/asl/schema/inheritance)** — extending one or more types. - **[Indexes](/asl/schema/indexes)** — `index on (...)` declarations. - **[Constraints](/asl/schema/constraints)** — composite (type-level) constraints.