diff --git a/lessons/08-programmability/02-functions/lesson.mdx b/lessons/08-programmability/02-functions/lesson.mdx new file mode 100644 index 0000000..8f59125 --- /dev/null +++ b/lessons/08-programmability/02-functions/lesson.mdx @@ -0,0 +1,167 @@ +A *function* is a named piece of logic stored in the database: give it arguments, get a value back. Instead of repeating the same expression in every query — or shipping it to the application — you name it once and call it like any built-in. + +The seed is a tiny `products` catalog with a net (pre-tax) `price`. We'll write functions that turn those prices into something useful. + + +SELECT * FROM products ORDER BY price; + + +## The simplest function: `LANGUAGE sql` + +A SQL function is just a single query with a name. Here's one that adds 21% tax to a net amount. The argument is referenced by name, and `RETURNS` declares the output type: + + +CREATE FUNCTION add_tax(amount numeric) +RETURNS numeric +LANGUAGE sql +AS $$ + SELECT amount * 1.21; +$$; + + +Now call it like any function — in the `SELECT` list, over every row: + + +SELECT name, price, add_tax(price) AS gross +FROM products +ORDER BY price; + + +A `LANGUAGE sql` function is the right default for anything expressible as one query. The body is a plain `SELECT`, so the planner can often *inline* it — splice the expression straight into the calling query and optimize the whole thing together, as if you'd never written a function. Zero call overhead. + +## Naming arguments, and `DEFAULT` + +Positional arguments get names in the signature; you can also give them defaults so callers may omit them. This version takes the tax `rate` as a second argument, defaulting to `0.21`: + + +CREATE FUNCTION net_to_gross(amount numeric, rate numeric DEFAULT 0.21) +RETURNS numeric +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT amount * (1 + rate); +$$; + + +Call it with one argument (the default 21% applies) or two (override the rate): + + +SELECT net_to_gross(100.00) AS default_rate, + net_to_gross(100.00, 0.10) AS reduced_rate; + + +## `LANGUAGE plpgsql`: variables and control flow + +When one query isn't enough — you need variables, branching, or loops — reach for PL/pgSQL, Postgres's procedural language. The body lives in a `DECLARE`/`BEGIN`/`END` block. This function buckets a price into a size label using `IF`/`ELSIF`/`ELSE`: + + +CREATE FUNCTION price_bucket(price numeric) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + label text; +BEGIN + IF price \< 20 THEN + label := 'cheap'; + ELSIF price \< 100 THEN + label := 'mid'; + ELSE + label := 'premium'; + END IF; + RETURN label; +END; +$$; + + +Use it just like the SQL functions — the caller can't tell which language a function is written in: + + +SELECT name, price, price_bucket(price) AS tier +FROM products +ORDER BY price; + + +The trade-off: PL/pgSQL is *not* inlinable. Each call runs the procedural body, so it costs more per row than an equivalent SQL function. Prefer `LANGUAGE sql` for simple expressions; reach for PL/pgSQL only when you genuinely need the control flow. + +## Returning a set of rows: `RETURNS TABLE` + +Functions can return more than a scalar. `RETURNS TABLE(...)` declares named, typed output columns, and the function yields rows — so you can call it in `FROM` like a table: + + +CREATE FUNCTION products_over(min_price numeric) +RETURNS TABLE(name text, gross numeric) +LANGUAGE sql +STABLE +AS $$ + SELECT p.name, net_to_gross(p.price) + FROM products p + WHERE p.price >= min_price; +$$; + + +Because it returns rows, it belongs in the `FROM` clause, not the `SELECT` list: + + +SELECT * FROM products_over(50) ORDER BY gross DESC; + + +`RETURNS SETOF sometype` is the shorthand when the rows match an existing table or type (e.g. `RETURNS SETOF products`); `RETURNS TABLE(...)` is best when you're shaping a custom result. + +## Volatility: `IMMUTABLE`, `STABLE`, `VOLATILE` + +You may have noticed `IMMUTABLE` and `STABLE` above. Every function has a *volatility* category telling the planner how much it can trust the result: + +- **`IMMUTABLE`** — same arguments always give the same result, forever. No table reads, no clock, no randomness. `net_to_gross` is pure arithmetic, so it qualifies. The planner may fold it to a constant at plan time, and — crucially — you can build an **expression index** on it. +- **`STABLE`** — result is fixed *within a single statement* but may change between statements (it reads tables, or depends on the current time within a query). `products_over` reads a table, so `STABLE` is the honest label. +- **`VOLATILE`** (the default if you say nothing) — may return a different value on every call: `random()`, `now()` semantics, anything with side effects. The planner re-evaluates it for every single row and won't optimize around it. + +The label is a *promise you make*, and Postgres takes you at your word. Mislabel a table-reading function `IMMUTABLE` and the planner may cache a stale value. When in doubt, pick the most restrictive category that's actually true. + +Because `net_to_gross` is `IMMUTABLE`, we can safely index its output — an expression index that makes `WHERE net_to_gross(price) > …` fast: + + +CREATE INDEX products_gross_idx ON products (net_to_gross(price)); + + +The `IMMUTABLE` label matters here: an index stores the function's output, so it's only correct if that output never changes for the same input. Postgres does *not* police this — it trusts the label and will happily build an index over a `VOLATILE` or `STABLE` function too, but such an index quietly rots as the results drift. Getting volatility right is what keeps the index honest. + +## Functions run inside your transaction + +A function executes as part of the statement that called it, inside the *same* transaction — there is no `COMMIT` or `ROLLBACK` inside a function body. If the outer statement rolls back, everything the function did rolls back with it. (Transaction control belongs to *procedures*, `CALL`ed on their own — a couple of lessons ahead.) + +## Your turn + +Write `net_to_gross(amount numeric, rate numeric DEFAULT 0.21) RETURNS numeric` — the tax-inclusive price, `amount * (1 + rate)` — as a `LANGUAGE sql` function, marked `IMMUTABLE` because it's pure arithmetic. You already saw one way above; here it is again so you can create it cleanly: + + +CREATE OR REPLACE FUNCTION net_to_gross(amount numeric, rate numeric DEFAULT 0.21) +RETURNS numeric +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT amount * (1 + rate); +$$; + + +Check it against a known input — `100.00` at the default 21% should give `121`: + + +SELECT net_to_gross(100.00) AS gross; + + + +Create `net_to_gross` as above. We'll call `net_to_gross(100.00)` and confirm it returns the gross price. + + +## What you learned + +- `CREATE FUNCTION name(args) RETURNS type` stores reusable logic; arguments are referenced by name and can have a `DEFAULT`. +- `LANGUAGE sql` is a single query — often *inlinable*, so it's the cheap default for simple expressions. +- `LANGUAGE plpgsql` is procedural: `DECLARE` variables, `IF`/`ELSIF`/`ELSE`, loops, and `RETURN`. More power, more per-call cost, no inlining. +- Return shapes: a scalar for the `SELECT` list, or `RETURNS TABLE(...)` / `SETOF` to yield rows you can query in `FROM`. +- Volatility (`IMMUTABLE`, `STABLE`, `VOLATILE`) is a promise to the planner — it drives constant-folding and gates expression indexes. Mark pure functions `IMMUTABLE`. +- Functions run inside the calling transaction; there's no `COMMIT` in a function — that's a procedure's job. + +Up next: triggers — running logic automatically on data changes. diff --git a/lessons/08-programmability/02-functions/lesson.yaml b/lessons/08-programmability/02-functions/lesson.yaml new file mode 100644 index 0000000..5dd0e3d --- /dev/null +++ b/lessons/08-programmability/02-functions/lesson.yaml @@ -0,0 +1,21 @@ +title: Functions +summary: Store reusable logic in the database with CREATE FUNCTION — SQL vs PL/pgSQL, return shapes, and volatility. +estimatedMinutes: 15 +tags: + - functions + - create-function + - plpgsql + - volatility + - immutable +authors: + - exekias +seed: seed.sql +checks: + - id: net-to-gross-works + type: query-returns + description: Write net_to_gross(amount, rate) and have it return the tax-inclusive price. + sql: SELECT net_to_gross(100.00) + expect: + rowCount: 1 + rows: + - [121.0000] diff --git a/lessons/08-programmability/02-functions/seed.sql b/lessons/08-programmability/02-functions/seed.sql new file mode 100644 index 0000000..d059257 --- /dev/null +++ b/lessons/08-programmability/02-functions/seed.sql @@ -0,0 +1,17 @@ +-- Seed for "02-functions": a tiny catalog to compute over. products holds a +-- handful of items with a net price; the lesson writes functions that turn +-- those prices into gross (tax-inclusive) amounts and bucket them by size. + +CREATE TABLE products ( + id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + price numeric(10,2) NOT NULL CHECK (price > 0) +); + +INSERT INTO products (name, price) VALUES + ('Notebook', 4.50), + ('Desk lamp', 29.99), + ('Keyboard', 79.00), + ('Monitor', 199.00), + ('Chair', 149.50), + ('Mouse', 19.90); diff --git a/lessons/08-programmability/module.yaml b/lessons/08-programmability/module.yaml new file mode 100644 index 0000000..51cb120 --- /dev/null +++ b/lessons/08-programmability/module.yaml @@ -0,0 +1,3 @@ +title: Programmability +difficulty: advanced +summary: Put logic in the database — views, functions, triggers, and stored procedures.