Skip to content

Functions — ASL

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.

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:

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 are name: type. Types map directly to Postgres: ASL scalars (str, int32, …) and your own scalar aliases/enums map to their SQL type (strTEXT), and any other name passes through verbatim as a raw Postgres type. A trailing [] makes an array.

@language sql
function first_tag(tags: text[]) -> text {
return tags[1];
};
@language sql
function total(a: int32, b: int32) -> int32 {
return a + b;
};

A function body is a single return <expr>;. The expression is raw Postgres, passed through verbatim, and axel wraps it for you:

@language wrapper
plpgsql (default) BEGIN RETURN <expr>; END;
sql SELECT <expr>;
@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 with an inline do ( … ) body.

Calling functions in Defaults & Extended Types

Section titled “Calling functions in Defaults & Extended Types”

Schema-defined functions and builtins can be called in property defaults or extended scalar types:

@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);
};
}

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).

Declared functions can be called directly in AQL query expressions, filters, and mutations:

@name CreateUserWithCode
insert User {
email := $email<str>,
referral_code := random_hex(8)
};

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.

Some Postgres functions take SQL as a stringcron.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 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:

@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()`);
};
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.

Attributes are declared as directives above the function, decorator-style. The value is omitted for flags and given for the rest:

Directive Emits
@language <lang> LANGUAGE <lang> (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 <n> COST <n>
@for <Type> nothing directly — marks a run-once setup function, invoked once (SELECT fn();) in the migration that first creates it, and tags it to <Type>. See Policies.

Attributes are emitted in a fixed order, so re-ordering directives never produces a spurious migration.

A -> trigger function takes no parameters (Postgres rule) and is what a trigger’s execute form runs. Its return yields the row:

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.

Functions can be declared with a receiver on custom scalar types to define read/write codec mappings:

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.