From 017e1bb36dd78d9c04b19a871a6c86a350c71f2b Mon Sep 17 00:00:00 2001 From: "Joshua (D) Drake" <136637981+ChronicallyJD@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:07:41 -0600 Subject: [PATCH] Add hstore transform, structured pg_raise, TRUNCATE/INSTEAD OF triggers, domain array/composite support Four PL/Ruby-parity features, each verified against PostgreSQL 18 with the full regression suite (25 plphp tests + jsonb_plphp + hstore_plphp, all green): - hstore_plphp: a TRANSFORM FOR TYPE hstore mapping hstore <-> PHP associative arrays (PHP null -> hstore NULL), companion to jsonb_plphp. - pg_raise(level, message [, detail [, hint [, sqlstate]]]): attaches DETAIL, HINT and a custom SQLSTATE like RAISE ... USING. Fields are preserved on a caught PgError, through nested SPI propagation, and now at top-level uncaught errors via a new field-stash bridge through the zend_bailout path -- which also fixes uncaught errors previously collapsing to SQLSTATE XX000 and losing DETAIL/HINT. - TRUNCATE (statement) and INSTEAD OF (view) triggers: previously the trigger handler errored on the unrecognized event/timing; $_TD now reports 'TRUNCATE' / 'INSTEAD OF', and INSTEAD OF returns drive the row. - Domains over array/composite: classified through getBaseType so an array domain arrives as (and returns) a PHP array; CHECK constraints still enforced. Item deferred: applying transforms inside trigger/SETOF/composite/SPI rows (plphp applies transforms only to top-level scalars today, as does jsonb) is a broad tuple-I/O change left as separate future work. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 21 ++ Makefile | 2 +- README.md | 2 +- doc/plphp.md | 64 +++++- expected/domains.out | 73 +++++++ expected/pgerror.out | 67 ++++++ expected/trigger2.out | 83 ++++++++ hstore_plphp/Makefile | 38 ++++ hstore_plphp/expected/hstore_plphp.out | 130 ++++++++++++ hstore_plphp/hstore_plphp--1.0.sql | 29 +++ hstore_plphp/hstore_plphp.c | 275 +++++++++++++++++++++++++ hstore_plphp/hstore_plphp.control | 6 + hstore_plphp/sql/hstore_plphp.sql | 84 ++++++++ plphp.c | 244 +++++++++++++++++++--- plphp_spi.c | 82 +++++++- plphp_spi.h | 2 + sql/domains.sql | 50 +++++ sql/pgerror.sql | 48 +++++ sql/trigger2.sql | 60 ++++++ 19 files changed, 1310 insertions(+), 50 deletions(-) create mode 100644 expected/domains.out create mode 100644 expected/trigger2.out create mode 100644 hstore_plphp/Makefile create mode 100644 hstore_plphp/expected/hstore_plphp.out create mode 100644 hstore_plphp/hstore_plphp--1.0.sql create mode 100644 hstore_plphp/hstore_plphp.c create mode 100644 hstore_plphp/hstore_plphp.control create mode 100644 hstore_plphp/sql/hstore_plphp.sql create mode 100644 sql/domains.sql create mode 100644 sql/trigger2.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b31b825..6e904aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,23 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ### Added +- **`hstore` transform (`hstore_plphp`).** A new `CREATE EXTENSION hstore_plphp` + adds `TRANSFORM FOR TYPE hstore`, mapping an `hstore` to a PHP associative + array of string keys to string-or-null values and back (a PHP `null` value + becomes an hstore `NULL`). Companion to the existing `jsonb_plphp`. +- **Structured `pg_raise`.** `pg_raise(level, message [, detail [, hint [, + sqlstate]]])` now attaches `DETAIL`, `HINT` and (for `ERROR`) a custom + `SQLSTATE`, mirroring PL/pgSQL's `RAISE ... USING`. The fields are readable + on a caught `PgError` and survive an uncaught error out to the client. +- **`TRUNCATE` and `INSTEAD OF` triggers.** Statement-level `TRUNCATE` triggers + (`$_TD['event'] = 'TRUNCATE'`) and view `INSTEAD OF` triggers + (`$_TD['when'] = 'INSTEAD OF'`) are now supported; previously the handler + errored on the unrecognized event/timing. +- **Domains over arrays and composites.** A function argument or result typed + as a domain is now handled as its base type, so a domain over an array + arrives as a PHP array (and can be returned as one) and a domain over a + composite as an associative array. The domain's `CHECK` constraints are + still enforced on results. Scalar domains were already transparent. - **Project logo and brand assets.** Light/dark PL/php logos and a square icon under `doc/assets/`, wired into the README and language reference. - **Error CONTEXT lines.** Messages raised while PL/php code runs carry a @@ -28,6 +45,10 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ### Fixed +- **Uncaught database errors lost their SQLSTATE, DETAIL and HINT.** An error + that unwound to the top of a PL/php call was reported with only its message + (and `SQLSTATE XX000`); it now carries the original `SQLSTATE`, `DETAIL` and + `HINT` through the `zend_bailout` reporting path. - **Backend crash when an error crossed nested PL/php calls.** A PostgreSQL error unwinding out of a handler's `zend_try` left Zend's bailout environment pointing into a dead stack frame; the next uncaught error then diff --git a/Makefile b/Makefile index 7350da8..1a17d06 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ PG_CFLAGS += $(ASAN_FLAGS) SHLIB_LINK = $(ASAN_FLAGS) -L$(PHP_LIBDIR) -l$(PHP_LIBNAME) $(shell $(PHP_CONFIG) --ldflags) # Regression tests. "init" installs the extension; keep it first. -REGRESS = init base shared trigger spi raise cargs pseudo srf out varnames validator compat txn evttrig subxact modules oninit cursor arrays coverage cookbook pgerror +REGRESS = init base shared trigger trigger2 spi raise cargs pseudo srf out varnames validator compat txn evttrig subxact modules oninit cursor arrays domains coverage cookbook pgerror PG_CONFIG ?= pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) diff --git a/README.md b/README.md index 2357c20..c029b9d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ SELECT hello('world'); -- Hello, world! | 🔐 **Transaction control** | `spi_commit` / `spi_rollback` in procedures, plus `subtransaction()` blocks. | | 🧰 **Utilities** | `quote_literal` / `quote_nullable` / `quote_ident`, `elog`, `$_SHARED`. | | 📦 **Session setup** | Anonymous `DO` blocks, `plphp_modules` autoloading, `plphp.on_init`, and a `plphp.start_proc` hook. | -| 🧬 **Native jsonb** | The `jsonb_plphp` transform: `TRANSFORM FOR TYPE jsonb` maps jsonb ⇄ PHP arrays directly. | +| 🧬 **Native jsonb & hstore** | The `jsonb_plphp` and `hstore_plphp` transforms map `jsonb` and `hstore` ⇄ PHP arrays directly. | See the [**language reference**](doc/plphp.md) for the full API, the [**cookbook**](doc/cookbook.md) for practical, regression-tested recipes, and diff --git a/doc/plphp.md b/doc/plphp.md index 923dc58..9a5879d 100644 --- a/doc/plphp.md +++ b/doc/plphp.md @@ -141,6 +141,38 @@ distinguish an empty list from an empty map, the same ambiguity `json_encode` has); JSON numbers arrive as PHP int when they fit, else float (precision beyond a double is lost). +### Native hstore via the `hstore_plphp` transform + +The companion `hstore_plphp` extension does for `hstore` what `jsonb_plphp` +does for `jsonb`: with `TRANSFORM FOR TYPE hstore`, an `hstore` argument +arrives as a PHP associative array of string keys to string-or-`null` values, +and a returned PHP array is converted back. A PHP `null` value becomes an +hstore `NULL`; keys and scalar values are stringified. + +```sql +CREATE EXTENSION hstore_plphp CASCADE; -- pulls in hstore and plphp + +CREATE FUNCTION tags_upper(hstore) RETURNS hstore +LANGUAGE plphp TRANSFORM FOR TYPE hstore AS $$ + $out = array(); + foreach ($args[0] as $k => $v) + $out[strtoupper($k)] = is_null($v) ? null : strtoupper($v); + return $out; +$$; +SELECT tags_upper('a=>x, b=>NULL'); -- "A"=>"X", "B"=>NULL +``` + +Returning something other than an array, or a nested array as a value, is +rejected with a clear error (hstore values are text or null). + +### Domains + +A domain is handled as its underlying base type: a scalar domain behaves like +its base scalar, a domain over an array arrives as a PHP array (and can be +returned as one), and a domain over a composite as an associative array. The +domain's `CHECK` constraints are enforced on a returned value, just as +PostgreSQL enforces them elsewhere. + ## Composite types and records A composite argument arrives as an associative array keyed by column name; a @@ -249,9 +281,9 @@ involved are available in the associative array `$_TD`: | `$_TD['relid']` | table OID | | `$_TD['relname']` | table name | | `$_TD['schemaname']` | schema name | -| `$_TD['when']` | `BEFORE` or `AFTER` | +| `$_TD['when']` | `BEFORE`, `AFTER`, or `INSTEAD OF` | | `$_TD['level']` | `ROW` or `STATEMENT` | -| `$_TD['event']` | `INSERT`, `UPDATE`, or `DELETE` | +| `$_TD['event']` | `INSERT`, `UPDATE`, `DELETE`, or `TRUNCATE` | | `$_TD['new']` | new row (INSERT/UPDATE), as an associative array | | `$_TD['old']` | old row (UPDATE/DELETE), as an associative array | | `$_TD['argc']` | number of trigger arguments | @@ -271,6 +303,13 @@ CREATE FUNCTION uppercase_name() RETURNS trigger LANGUAGE plphp AS $$ $$; ``` +The same return convention drives **INSTEAD OF ... FOR EACH ROW** triggers on +views: the trigger does the real work (typically against a base table) and +returns `;`/`'MODIFY'` to mark the row handled, or `'SKIP'` to skip it. A +statement-level **TRUNCATE** trigger fires with `$_TD['event'] = 'TRUNCATE'` +and no row; `WHEN (...)` conditions are evaluated by PostgreSQL, so the +function only runs for matching rows. + ## Event trigger functions An event trigger function is declared `RETURNS event_trigger` and fires on DDL @@ -460,9 +499,24 @@ elog('ERROR', 'stop right here'); // aborts, like a PostgreSQL ERROR ``` The level is one of `DEBUG`, `LOG`, `INFO`, `NOTICE`, `WARNING`, or `ERROR` -(case-insensitive). `pg_raise(level, message)` is an older, narrower spelling -that accepts `notice`, `warning`, or `error`. Anything PHP writes to standard -output is also forwarded to the PostgreSQL log. +(case-insensitive). Anything PHP writes to standard output is also forwarded to +the PostgreSQL log. + +`pg_raise(level, message [, detail [, hint [, sqlstate]]])` accepts `notice`, +`warning`, or `error`, and optionally attaches a `DETAIL`, a `HINT`, and (for +`error`) a custom five-character `SQLSTATE` — the equivalent of PL/pgSQL's +`RAISE ... USING`: + +```php +pg_raise('error', 'balance would go negative', + 'account 42 has 10, tried to withdraw 15', // DETAIL + 'deposit first, or withdraw less', // HINT + '22003'); // SQLSTATE +``` + +These fields are readable on a caught `PgError` (`getSQLState()`, `getDetail()`, +`getHint()`) and, when the error is left uncaught, appear on the error reported +to the client. ## Shared data: `$_SHARED` diff --git a/expected/domains.out b/expected/domains.out new file mode 100644 index 0000000..516a559 --- /dev/null +++ b/expected/domains.out @@ -0,0 +1,73 @@ +-- +-- Domain types. A PL/php function sees a domain as its underlying base type +-- (scalar, array or composite), and a value returned into a domain result is +-- checked against the domain's constraints. +-- +CREATE DOMAIN posint AS int CHECK (VALUE > 0); +CREATE DOMAIN shortstr AS text CHECK (length(VALUE) <= 5); +CREATE DOMAIN intvec AS int[] CHECK (cardinality(VALUE) > 0); +-- A scalar domain argument arrives as the base value; the CHECK on the input +-- has already been enforced by the caller. +CREATE FUNCTION dom_scalar(posint, shortstr) RETURNS text LANGUAGE plphp AS $$ + return $args[0] . "/" . strtoupper($args[1]); +$$; +SELECT dom_scalar(7, 'abc'); + dom_scalar +------------ + 7/ABC +(1 row) + +-- Returning into a scalar domain enforces its CHECK. +CREATE FUNCTION dom_double(int) RETURNS posint LANGUAGE plphp AS $$ + return $args[0] * 2; +$$; +SELECT dom_double(21); + dom_double +------------ + 42 +(1 row) + +SELECT dom_double(-1); -- violates posint +ERROR: value for domain posint violates check constraint "posint_check" +CONTEXT: PL/php function "dom_double" +-- A domain over an array arrives as a PHP array, and a PHP array can be +-- returned into it (the domain CHECK still applies). +CREATE FUNCTION dom_arr_in(intvec) RETURNS int LANGUAGE plphp AS $$ + return is_array($args[0]) ? array_sum($args[0]) : -1; +$$; +SELECT dom_arr_in('{10,20,30}'); + dom_arr_in +------------ + 60 +(1 row) + +CREATE FUNCTION dom_arr_out(int) RETURNS intvec LANGUAGE plphp AS $$ + $out = array(); + for ($i = 1; $i <= $args[0]; $i++) + $out[] = $i * $i; + return $out; +$$; +SELECT dom_arr_out(4); + dom_arr_out +------------- + {1,4,9,16} +(1 row) + +SELECT dom_arr_out(0); -- empty array violates intvec's CHECK +ERROR: value for domain intvec violates check constraint "intvec_check" +CONTEXT: PL/php function "dom_arr_out" +-- A domain used as a composite field is unwrapped there too. +CREATE TYPE drow AS (id posint, tag shortstr); +CREATE FUNCTION dom_field(drow) RETURNS text LANGUAGE plphp AS $$ + return $args[0]['id'] . ":" . $args[0]['tag']; +$$; +SELECT dom_field(ROW(3, 'ok')::drow); + dom_field +----------- + 3:ok +(1 row) + +DROP FUNCTION dom_scalar(posint, shortstr), dom_double(int), + dom_arr_in(intvec), dom_arr_out(int), dom_field(drow); +DROP TYPE drow; +DROP DOMAIN posint, shortstr, intvec; diff --git a/expected/pgerror.out b/expected/pgerror.out index e01cc0e..83d2974 100644 --- a/expected/pgerror.out +++ b/expected/pgerror.out @@ -142,5 +142,72 @@ SELECT err_outer_catch(); caught nested: division by zero at line 2 (1 row) +-- pg_raise can attach DETAIL, HINT and a custom SQLSTATE, like PL/pgSQL's +-- RAISE ... USING. Caught, all four fields are readable. +CREATE FUNCTION err_raise_using() RETURNS text LANGUAGE plphp AS $$ + try { + pg_raise('error', 'bad thing', 'because reasons', 'do X', '22023'); + } catch (PgError $e) { + return sprintf("[%s] %s / detail=%s / hint=%s", + $e->getSQLState(), $e->getMessage(), $e->getDetail(), $e->getHint()); + } +$$; +SELECT err_raise_using(); + err_raise_using +-------------------------------------------------------- + [22023] bad thing / detail=because reasons / hint=do X +(1 row) + +-- The custom SQLSTATE/detail/hint survive an uncaught trip out through the +-- PostgreSQL error layer and back into a PgError caught one call up. +CREATE FUNCTION err_custom_inner() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'custom failure', 'the gory details', 'try harder', '22012'); +$$; +CREATE FUNCTION err_custom_outer() RETURNS text LANGUAGE plphp AS $$ + try { + spi_exec("select err_custom_inner()"); + } catch (PgError $e) { + return $e->getSQLState() . " | " . $e->getDetail() . " | " . $e->getHint(); + } + return "not reached"; +$$; +SELECT err_custom_outer(); + err_custom_outer +--------------------------------------- + 22012 | the gory details | try harder +(1 row) + +-- Uncaught, the DETAIL and HINT lines show up in the error itself (psql +-- prints them at default verbosity). +CREATE FUNCTION err_uncaught_using() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'boom', 'what went wrong', 'what to do'); +$$; +SELECT err_uncaught_using(); +ERROR: boom at line 2 +DETAIL: what went wrong +HINT: what to do +CONTEXT: PL/php function "err_uncaught_using" +-- A NOTICE can carry DETAIL/HINT too. +CREATE FUNCTION note_using() RETURNS void LANGUAGE plphp AS $$ + pg_raise('notice', 'heads up', 'more info', 'a suggestion'); +$$; +SELECT note_using(); +NOTICE: heads up +DETAIL: more info +HINT: a suggestion + note_using +------------ + +(1 row) + +-- An invalid SQLSTATE (not five upper-case/digit characters) is rejected. +CREATE FUNCTION err_bad_sqlstate() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'x', null, null, 'abcde'); +$$; +SELECT err_bad_sqlstate(); +ERROR: pg_raise: SQLSTATE must contain only digits and upper-case ASCII letters at line 2 +CONTEXT: PL/php function "err_bad_sqlstate" +DROP FUNCTION err_raise_using(), err_custom_inner(), err_custom_outer(), + err_uncaught_using(), note_using(), err_bad_sqlstate(); DROP FUNCTION err_inner(), err_outer(), err_outer_catch(); DROP TABLE errt; diff --git a/expected/trigger2.out b/expected/trigger2.out new file mode 100644 index 0000000..3e38ba2 --- /dev/null +++ b/expected/trigger2.out @@ -0,0 +1,83 @@ +-- +-- Trigger coverage beyond BEFORE/AFTER INSERT/UPDATE/DELETE rows: +-- statement-level TRUNCATE triggers, INSTEAD OF view triggers, and WHEN(...). +-- +-- A statement-level TRUNCATE trigger fires with $_TD['event'] = 'TRUNCATE'. +CREATE TABLE trunc_t (a int); +INSERT INTO trunc_t VALUES (1), (2), (3); +CREATE FUNCTION trunc_trig() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "truncate trigger: when={$_TD['when']} event={$_TD['event']} level={$_TD['level']}"); + return null; +$$; +CREATE TRIGGER trunc_before BEFORE TRUNCATE ON trunc_t + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig(); +CREATE TRIGGER trunc_after AFTER TRUNCATE ON trunc_t + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig(); +TRUNCATE trunc_t; +NOTICE: truncate trigger: when=BEFORE event=TRUNCATE level=STATEMENT +NOTICE: truncate trigger: when=AFTER event=TRUNCATE level=STATEMENT +SELECT count(*) AS rows_after_truncate FROM trunc_t; + rows_after_truncate +--------------------- + 0 +(1 row) + +DROP TABLE trunc_t; +-- INSTEAD OF triggers on a view: the trigger does the real work against the +-- base table and returns (non-NULL) to mark the row handled. 'when' is +-- 'INSTEAD OF'. +CREATE TABLE base_t (id int PRIMARY KEY, val text); +CREATE VIEW base_v AS SELECT id, val FROM base_t; +CREATE FUNCTION base_v_ins() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "INSTEAD OF {$_TD['event']} (when={$_TD['when']}, level={$_TD['level']})"); + spi_exec("insert into base_t values (" . intval($_TD['new']['id']) . ", " . + quote_literal(strtoupper($_TD['new']['val'])) . ")"); + return null; /* row handled */ +$$; +CREATE FUNCTION base_v_del() RETURNS trigger LANGUAGE plphp AS $$ + spi_exec("delete from base_t where id = " . intval($_TD['old']['id'])); + return null; +$$; +CREATE TRIGGER base_v_ins_t INSTEAD OF INSERT ON base_v + FOR EACH ROW EXECUTE PROCEDURE base_v_ins(); +CREATE TRIGGER base_v_del_t INSTEAD OF DELETE ON base_v + FOR EACH ROW EXECUTE PROCEDURE base_v_del(); +INSERT INTO base_v VALUES (1, 'hello'), (2, 'world'); +NOTICE: INSTEAD OF INSERT (when=INSTEAD OF, level=ROW) +NOTICE: INSTEAD OF INSERT (when=INSTEAD OF, level=ROW) +SELECT * FROM base_t ORDER BY id; + id | val +----+------- + 1 | HELLO + 2 | WORLD +(2 rows) + +DELETE FROM base_v WHERE id = 1; +SELECT * FROM base_t ORDER BY id; + id | val +----+------- + 2 | WORLD +(1 row) + +DROP VIEW base_v; +DROP TABLE base_t; +-- WHEN(...) gates whether the trigger fires at all (evaluated by PostgreSQL, +-- so the function only runs for matching rows). +CREATE TABLE when_t (a int, b text); +CREATE FUNCTION when_trig() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "fired for a={$_TD['new']['a']}"); + return null; +$$; +CREATE TRIGGER when_big BEFORE INSERT ON when_t + FOR EACH ROW WHEN (NEW.a > 10) EXECUTE PROCEDURE when_trig(); +INSERT INTO when_t VALUES (5, 'small'), (20, 'big'), (30, 'bigger'); +NOTICE: fired for a=20 +NOTICE: fired for a=30 +SELECT count(*) AS inserted FROM when_t; + inserted +---------- + 3 +(1 row) + +DROP TABLE when_t; +DROP FUNCTION trunc_trig(), base_v_ins(), base_v_del(), when_trig(); diff --git a/hstore_plphp/Makefile b/hstore_plphp/Makefile new file mode 100644 index 0000000..7beeea5 --- /dev/null +++ b/hstore_plphp/Makefile @@ -0,0 +1,38 @@ +# hstore_plphp - transform between hstore and PL/php arrays +# +# Build after (or alongside) PL/php itself; requires the same PHP embed +# development files, and the hstore and plphp extensions at CREATE +# EXTENSION time: +# make PG_CONFIG=/path/to/pg_config +# sudo make install +# (in a database) CREATE EXTENSION hstore_plphp CASCADE; + +MODULE_big = hstore_plphp +OBJS = hstore_plphp.o + +EXTENSION = hstore_plphp +DATA = hstore_plphp--1.0.sql + +# PHP embed SAPI compile/link flags, discovered via php-config. +PHP_CONFIG ?= php-config +PHP_INCLUDES := $(shell $(PHP_CONFIG) --includes) +PHP_LIBDIR := $(shell $(PHP_CONFIG) --prefix)/lib +# Prefer the library matching PHP_CONFIG's version (e.g. libphp8.3.so); with +# several PHP versions installed, a bare "libphp*.so" glob would pick the +# unversioned symlink of whichever version owns it. +PHP_VERSION := $(shell $(PHP_CONFIG) --version | cut -d. -f1-2) +PHP_LIBNAME := $(patsubst lib%.so,%,$(notdir $(firstword $(wildcard \ + $(PHP_LIBDIR)/libphp$(PHP_VERSION).so /usr/lib/libphp$(PHP_VERSION).so /usr/lib/*/libphp$(PHP_VERSION).so \ + $(PHP_LIBDIR)/libphp*.so /usr/lib/libphp*.so /usr/lib/*/libphp*.so)))) + +PG_CPPFLAGS = $(PHP_INCLUDES) +# Extra flags hook, e.g. ASAN_FLAGS="-fsanitize=address" for sanitizer builds +ASAN_FLAGS ?= +PG_CFLAGS += $(ASAN_FLAGS) +SHLIB_LINK = $(ASAN_FLAGS) -L$(PHP_LIBDIR) -l$(PHP_LIBNAME) $(shell $(PHP_CONFIG) --ldflags) + +REGRESS = hstore_plphp + +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/hstore_plphp/expected/hstore_plphp.out b/hstore_plphp/expected/hstore_plphp.out new file mode 100644 index 0000000..bb3b748 --- /dev/null +++ b/hstore_plphp/expected/hstore_plphp.out @@ -0,0 +1,130 @@ +-- +-- hstore <-> PHP array transform. Functions must opt in with +-- TRANSFORM FOR TYPE hstore. +-- +CREATE EXTENSION hstore_plphp CASCADE; +NOTICE: installing required extension "hstore" +NOTICE: installing required extension "plphp" +-- An hstore argument arrives as an associative array of string => string/null. +CREATE FUNCTION ht_classes(hstore) RETURNS text +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $h = $args[0]; + ksort($h); + $out = []; + foreach ($h as $k => $v) + $out[] = "$k=" . (is_null($v) ? 'NULL' : gettype($v) . "($v)"); + return implode(' ', $out); +$$; +SELECT ht_classes('a=>1, b=>NULL, "key with spaces"=>"and \"quotes\""'::hstore); + ht_classes +--------------------------------------------------------- + a=string(1) b=NULL key with spaces=string(and "quotes") +(1 row) + +-- Round-trip: modify and return; null becomes an hstore NULL. +CREATE FUNCTION ht_upcase(hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $out = []; + foreach ($args[0] as $k => $v) + $out[strtoupper($k)] = is_null($v) ? null : strtoupper($v); + return $out; +$$; +SELECT ht_upcase('name=>widget, note=>NULL'); + ht_upcase +-------------------------------- + "NAME"=>"WIDGET", "NOTE"=>NULL +(1 row) + +-- Build an hstore from PHP data: keys and scalar values are stringified, +-- and a PHP null becomes an hstore NULL. +CREATE FUNCTION ht_build(int) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return ['n' => $args[0], 'double' => $args[0] * 2, + 'ok' => $args[0] % 2 == 1, 'gone' => null]; +$$; +SELECT ht_build(3); + ht_build +-------------------------------------------------- + "n"=>"3", "ok"=>"1", "gone"=>NULL, "double"=>"6" +(1 row) + +SELECT ht_build(4)->'double' AS doubled; + doubled +--------- + 8 +(1 row) + +-- Idiomatic array work: merge and prune the nulls in one step. +CREATE FUNCTION ht_merge(a hstore, b hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $m = array_merge($args[0], $args[1]); + return array_filter($m, fn($v) => !is_null($v)); +$$; +SELECT ht_merge('a=>1, b=>2', 'b=>20, c=>NULL, d=>4'); + ht_merge +------------------------------- + "a"=>"1", "b"=>"20", "d"=>"4" +(1 row) + +-- The empty hstore round-trips. +CREATE FUNCTION ht_empty(hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return $args[0]; +$$; +SELECT ht_empty(''::hstore); + ht_empty +---------- + +(1 row) + +-- A NULL hstore argument is null; returning null is SQL NULL. +SELECT ht_empty(NULL) IS NULL AS null_roundtrip; + null_roundtrip +---------------- + t +(1 row) + +-- Returning something other than an array is rejected cleanly. +CREATE FUNCTION ht_bad() RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return "not an array"; +$$; +SELECT ht_bad(); +ERROR: cannot transform PHP value to hstore +HINT: An hstore value is built from a PHP array. +CONTEXT: PL/php function "ht_bad" +-- A nested value (array) is not a valid hstore value. +CREATE FUNCTION ht_nested() RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return ['k' => ['nested']]; +$$; +SELECT ht_nested(); +ERROR: cannot transform nested PHP value to an hstore value +HINT: hstore values are text or null; stringify first. +CONTEXT: PL/php function "ht_nested" +-- Without the TRANSFORM clause, hstore still arrives as its text form. +CREATE FUNCTION ht_untransformed(hstore) RETURNS text LANGUAGE plphp AS $$ + return gettype($args[0]); +$$; +SELECT ht_untransformed('a=>1'::hstore); + ht_untransformed +------------------ + string +(1 row) + +DROP EXTENSION hstore_plphp CASCADE; +NOTICE: drop cascades to 7 other objects +DETAIL: drop cascades to function ht_classes(hstore) +drop cascades to function ht_upcase(hstore) +drop cascades to function ht_build(integer) +drop cascades to function ht_merge(hstore,hstore) +drop cascades to function ht_empty(hstore) +drop cascades to function ht_bad() +drop cascades to function ht_nested() diff --git a/hstore_plphp/hstore_plphp--1.0.sql b/hstore_plphp/hstore_plphp--1.0.sql new file mode 100644 index 0000000..9aea684 --- /dev/null +++ b/hstore_plphp/hstore_plphp--1.0.sql @@ -0,0 +1,29 @@ +/* hstore_plphp--1.0.sql + * + * Transform between hstore and PL/php arrays. + * + * With this transform, a function created with TRANSFORM FOR TYPE hstore + * receives hstore arguments as a PHP associative array of string keys to + * string (or null) values, and may return a PHP array into an hstore + * result. + */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION hstore_plphp" to load this file. \quit + +CREATE FUNCTION hstore_to_plphp(internal) +RETURNS internal +LANGUAGE C STRICT IMMUTABLE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION plphp_to_hstore(internal) +RETURNS hstore +LANGUAGE C STRICT IMMUTABLE +AS 'MODULE_PATHNAME'; + +CREATE TRANSFORM FOR hstore LANGUAGE plphp ( + FROM SQL WITH FUNCTION hstore_to_plphp(internal), + TO SQL WITH FUNCTION plphp_to_hstore(internal)); + +COMMENT ON TRANSFORM FOR hstore LANGUAGE plphp IS + 'transform between hstore and PL/php arrays'; diff --git a/hstore_plphp/hstore_plphp.c b/hstore_plphp/hstore_plphp.c new file mode 100644 index 0000000..70c7583 --- /dev/null +++ b/hstore_plphp/hstore_plphp.c @@ -0,0 +1,275 @@ +/********************************************************************** + * hstore_plphp.c - TRANSFORM between hstore and PL/php arrays. + * + * hstore_to_plphp (FROM SQL) converts an hstore datum into a PHP + * associative array of string keys to string (or null) values. + * plphp_to_hstore (TO SQL) converts a PHP array back: keys and scalar + * values are stringified, and a PHP null value becomes an hstore NULL. + * + * Like hstore_plperl (and unlike touching hstore's internal + * representation), this module converts through the hstore extension's + * own SQL-callable functions, hstore_to_array(hstore) and hstore(text[]), + * whose OIDs are resolved at run time. The hstore type is found through + * the pg_transform row that points at this very function, so the lookup + * is independent of schemas and search_path. The resolved OID is cached + * in fn_extra. + * + * The PHP interpreter is owned and initialized by PL/php; these functions + * only run inside a PL/php function call, so it is always up. + * + * This software is copyright (c) Command Prompt Inc. Same license as + * PL/php itself; see plphp.c. + **********************************************************************/ + +#include "postgres.h" + +#include "access/genam.h" +#include "access/htup_details.h" +#if PG_VERSION_NUM >= 120000 +#include "access/table.h" +#else +#include "access/heapam.h" +#define table_open(rel, lock) heap_open(rel, lock) +#define table_close(rel, lock) heap_close(rel, lock) +#endif +#include "catalog/pg_proc.h" +#include "catalog/pg_transform.h" +#include "catalog/pg_type.h" +#include "fmgr.h" +#include "utils/array.h" +#include "utils/builtins.h" +#include "utils/syscache.h" + +/* + * These are defined again in php.h, so undef them to avoid some + * cpp warnings (same dance as jsonb_plphp.c). + */ +#undef PACKAGE_BUGREPORT +#undef PACKAGE_NAME +#undef PACKAGE_STRING +#undef PACKAGE_TARNAME +#undef PACKAGE_VERSION + +#include "php.h" +#include "Zend/zend_API.h" + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(hstore_to_plphp); +PG_FUNCTION_INFO_V1(plphp_to_hstore); + +/* + * The hstore type OID, found via the pg_transform row whose FromSQL (or + * ToSQL) function is this transform function itself. + */ +static Oid +hstore_type_from_transform(Oid trf_fn, bool fromsql) +{ + Relation rel; + SysScanDesc scan; + HeapTuple tup; + Oid result = InvalidOid; + + rel = table_open(TransformRelationId, AccessShareLock); + scan = systable_beginscan(rel, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Form_pg_transform t = (Form_pg_transform) GETSTRUCT(tup); + + if ((fromsql ? t->trffromsql : t->trftosql) == trf_fn) + { + result = t->trftype; + break; + } + } + systable_endscan(scan); + table_close(rel, AccessShareLock); + + if (!OidIsValid(result)) + elog(ERROR, "could not find pg_transform entry for function %u", trf_fn); + return result; +} + +/* + * The OID of an hstore-extension function living in the hstore type's own + * namespace: hstore_to_array(hstore) or hstore(text[]). + */ +static Oid +lookup_hstore_fn(Oid hstore_oid, const char *name, Oid argtype) +{ + HeapTuple typtup; + Oid nsp; + oidvector *argv; + HeapTuple ftup; + Oid result; + + typtup = SearchSysCache1(TYPEOID, ObjectIdGetDatum(hstore_oid)); + if (!HeapTupleIsValid(typtup)) + elog(ERROR, "cache lookup failed for type %u", hstore_oid); + nsp = ((Form_pg_type) GETSTRUCT(typtup))->typnamespace; + ReleaseSysCache(typtup); + + argv = buildoidvector(&argtype, 1); + ftup = SearchSysCache3(PROCNAMEARGSNSP, CStringGetDatum(name), + PointerGetDatum(argv), ObjectIdGetDatum(nsp)); + if (!HeapTupleIsValid(ftup)) + elog(ERROR, "could not find the hstore extension's %s function", name); +#if PG_VERSION_NUM >= 120000 + result = ((Form_pg_proc) GETSTRUCT(ftup))->oid; +#else + result = HeapTupleGetOid(ftup); +#endif + ReleaseSysCache(ftup); + return result; +} + +/* Resolve (once per FmgrInfo) the conversion function this direction uses. */ +static Oid +conversion_fn(FunctionCallInfo fcinfo, bool fromsql) +{ + Oid *cached = (Oid *) fcinfo->flinfo->fn_extra; + + if (cached == NULL) + { + Oid hstore_oid = hstore_type_from_transform(fcinfo->flinfo->fn_oid, + fromsql); + + cached = (Oid *) MemoryContextAlloc(fcinfo->flinfo->fn_mcxt, sizeof(Oid)); + if (fromsql) + *cached = lookup_hstore_fn(hstore_oid, "hstore_to_array", + hstore_oid); + else + *cached = lookup_hstore_fn(hstore_oid, "hstore", TEXTARRAYOID); + fcinfo->flinfo->fn_extra = cached; + } + return *cached; +} + +/* --------------------------------------------------------------------- + * hstore -> PHP associative array + * ------------------------------------------------------------------- */ + +Datum +hstore_to_plphp(PG_FUNCTION_ARGS) +{ + Oid to_array = conversion_fn(fcinfo, true); + ArrayType *arr; + Datum *elems; + bool *nulls; + int nelems; + int i; + zval *z; + + /* hstore_to_array: alternating key, value; a NULL value stays NULL */ + arr = DatumGetArrayTypeP(OidFunctionCall1(to_array, PG_GETARG_DATUM(0))); + deconstruct_array(arr, TEXTOID, -1, false, 'i', &elems, &nulls, &nelems); + + z = (zval *) emalloc(sizeof(zval)); + array_init(z); + + for (i = 0; i + 1 < nelems; i += 2) + { + text *kt = DatumGetTextPP(elems[i]); + char *key = pnstrdup(VARDATA_ANY(kt), VARSIZE_ANY_EXHDR(kt)); + zval v; + + if (nulls[i + 1]) + ZVAL_NULL(&v); + else + { + text *vt = DatumGetTextPP(elems[i + 1]); + + ZVAL_STRINGL(&v, VARDATA_ANY(vt), VARSIZE_ANY_EXHDR(vt)); + } + add_assoc_zval(z, key, &v); + pfree(key); + } + + PG_RETURN_POINTER(z); +} + +/* --------------------------------------------------------------------- + * PHP array -> hstore + * ------------------------------------------------------------------- */ + +Datum +plphp_to_hstore(PG_FUNCTION_ARGS) +{ + Oid from_array = conversion_fn(fcinfo, false); + zval *in = (zval *) PG_GETARG_POINTER(0); + HashTable *ht; + Datum *elems; + bool *nulls; + int npairs; + int next; + int dims[1]; + int lbs[1] = {1}; + ArrayType *arr; + zend_string *strkey; + zend_ulong numkey; + zval *el; + + ZVAL_DEREF(in); + if (Z_TYPE_P(in) != IS_ARRAY) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("cannot transform PHP value to hstore"), + errhint("An hstore value is built from a PHP array."))); + + ht = Z_ARRVAL_P(in); + npairs = zend_hash_num_elements(ht); + elems = (Datum *) palloc(npairs * 2 * sizeof(Datum)); + nulls = (bool *) palloc(npairs * 2 * sizeof(bool)); + next = 0; + + ZEND_HASH_FOREACH_KEY_VAL(ht, numkey, strkey, el) + { + /* key: a string key as-is, an integer key rendered as text */ + if (strkey != NULL) + elems[next] = PointerGetDatum(cstring_to_text_with_len(ZSTR_VAL(strkey), + ZSTR_LEN(strkey))); + else + { + char *k = psprintf(ZEND_LONG_FMT, (zend_long) numkey); + + elems[next] = PointerGetDatum(cstring_to_text(k)); + pfree(k); + } + nulls[next] = false; + next++; + + /* value: PHP null -> hstore NULL; scalars stringified; else reject */ + ZVAL_DEREF(el); + if (Z_TYPE_P(el) == IS_NULL) + { + elems[next] = (Datum) 0; + nulls[next] = true; + } + else if (Z_TYPE_P(el) == IS_ARRAY || Z_TYPE_P(el) == IS_OBJECT) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("cannot transform nested PHP value to an hstore value"), + errhint("hstore values are text or null; stringify first."))); + else + { + zend_string *vstr = zval_get_string(el); + + elems[next] = PointerGetDatum(cstring_to_text_with_len(ZSTR_VAL(vstr), + ZSTR_LEN(vstr))); + nulls[next] = false; + zend_string_release(vstr); + } + next++; + } + ZEND_HASH_FOREACH_END(); + + dims[0] = next; + arr = construct_md_array(elems, nulls, 1, dims, lbs, + TEXTOID, -1, false, 'i'); + + PG_RETURN_DATUM(OidFunctionCall1(from_array, PointerGetDatum(arr))); +} + +/* + * vim:ts=4:sw=4:cino=(0 + */ diff --git a/hstore_plphp/hstore_plphp.control b/hstore_plphp/hstore_plphp.control new file mode 100644 index 0000000..bab38cd --- /dev/null +++ b/hstore_plphp/hstore_plphp.control @@ -0,0 +1,6 @@ +# hstore_plphp extension +comment = 'transform between hstore and PL/php arrays' +default_version = '1.0' +module_pathname = '$libdir/hstore_plphp' +relocatable = true +requires = 'hstore, plphp' diff --git a/hstore_plphp/sql/hstore_plphp.sql b/hstore_plphp/sql/hstore_plphp.sql new file mode 100644 index 0000000..4298270 --- /dev/null +++ b/hstore_plphp/sql/hstore_plphp.sql @@ -0,0 +1,84 @@ +-- +-- hstore <-> PHP array transform. Functions must opt in with +-- TRANSFORM FOR TYPE hstore. +-- +CREATE EXTENSION hstore_plphp CASCADE; + +-- An hstore argument arrives as an associative array of string => string/null. +CREATE FUNCTION ht_classes(hstore) RETURNS text +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $h = $args[0]; + ksort($h); + $out = []; + foreach ($h as $k => $v) + $out[] = "$k=" . (is_null($v) ? 'NULL' : gettype($v) . "($v)"); + return implode(' ', $out); +$$; +SELECT ht_classes('a=>1, b=>NULL, "key with spaces"=>"and \"quotes\""'::hstore); + +-- Round-trip: modify and return; null becomes an hstore NULL. +CREATE FUNCTION ht_upcase(hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $out = []; + foreach ($args[0] as $k => $v) + $out[strtoupper($k)] = is_null($v) ? null : strtoupper($v); + return $out; +$$; +SELECT ht_upcase('name=>widget, note=>NULL'); + +-- Build an hstore from PHP data: keys and scalar values are stringified, +-- and a PHP null becomes an hstore NULL. +CREATE FUNCTION ht_build(int) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return ['n' => $args[0], 'double' => $args[0] * 2, + 'ok' => $args[0] % 2 == 1, 'gone' => null]; +$$; +SELECT ht_build(3); +SELECT ht_build(4)->'double' AS doubled; + +-- Idiomatic array work: merge and prune the nulls in one step. +CREATE FUNCTION ht_merge(a hstore, b hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + $m = array_merge($args[0], $args[1]); + return array_filter($m, fn($v) => !is_null($v)); +$$; +SELECT ht_merge('a=>1, b=>2', 'b=>20, c=>NULL, d=>4'); + +-- The empty hstore round-trips. +CREATE FUNCTION ht_empty(hstore) RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return $args[0]; +$$; +SELECT ht_empty(''::hstore); + +-- A NULL hstore argument is null; returning null is SQL NULL. +SELECT ht_empty(NULL) IS NULL AS null_roundtrip; + +-- Returning something other than an array is rejected cleanly. +CREATE FUNCTION ht_bad() RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return "not an array"; +$$; +SELECT ht_bad(); + +-- A nested value (array) is not a valid hstore value. +CREATE FUNCTION ht_nested() RETURNS hstore +TRANSFORM FOR TYPE hstore +LANGUAGE plphp AS $$ + return ['k' => ['nested']]; +$$; +SELECT ht_nested(); + +-- Without the TRANSFORM clause, hstore still arrives as its text form. +CREATE FUNCTION ht_untransformed(hstore) RETURNS text LANGUAGE plphp AS $$ + return gettype($args[0]); +$$; +SELECT ht_untransformed('a=>1'::hstore); + +DROP EXTENSION hstore_plphp CASCADE; diff --git a/plphp.c b/plphp.c index fae22ef..8bd6ca6 100644 --- a/plphp.c +++ b/plphp.c @@ -205,10 +205,16 @@ static StringInfo currmsg = NULL; /* * for PHP <-> Postgres error message passing * - * XXX -- it would be much better if we could save errcontext, - * errhint, etc as well. + * error_msg carries the message across a zend_bailout to the zend_catch that + * turns it into a Postgres ERROR. For an uncaught PgError the exception + * object is no longer reachable from the error callback, so its SQLSTATE / + * DETAIL / HINT are stashed alongside (by plphp_stash_error_fields, at throw + * time) and re-attached at the same zend_catch sites. */ static char *error_msg = NULL; +static char *error_sqlstate = NULL; +static char *error_detail = NULL; +static char *error_hint = NULL; /* GUC: PHP code to run once when the interpreter is initialized */ static char *plphp_on_init = NULL; @@ -255,8 +261,20 @@ static zval *plphp_call_php_trig(plphp_proc_desc *desc, static void plphp_error_cb(int type, zend_string *error_filename, const uint32_t error_lineno, zend_string *message); +/* + * pg_noreturn is the prefix noreturn specifier as of PG 18; older releases + * used the pg_attribute_noreturn() suffix macro instead. + */ +#ifndef pg_noreturn +#define pg_noreturn pg_attribute_noreturn() +#endif + static bool is_valid_php_identifier(char *name); +static char *plphp_pop_exception(char **p_sqlstate, char **p_detail, + char **p_hint); static char *plphp_pop_exception_message(void); +pg_noreturn static void plphp_report_php_error(char *msg, char *sqlstate, + char *detail, char *hint); /* * Error context callbacks: every message raised while PL/php code runs gets @@ -844,7 +862,8 @@ plphp_call_handler(PG_FUNCTION_ARGS) strncpy(str, error_msg, sizeof(str)); pfree(error_msg); error_msg = NULL; - elog(ERROR, "%s", str); + plphp_report_php_error(str, error_sqlstate, + error_detail, error_hint); } else elog(ERROR, "fatal error"); @@ -947,14 +966,20 @@ plphp_inline_handler(PG_FUNCTION_ARGS) &retval, 0, NULL) == FAILURE) elog(ERROR, "could not execute inline code block"); - exmsg = plphp_pop_exception_message(); + { + char *sqlstate, + *detail, + *hint; - zval_ptr_dtor(&funcname_zv); - zval_ptr_dtor(&retval); - zend_hash_str_del(CG(function_table), funcname, strlen(funcname)); + exmsg = plphp_pop_exception(&sqlstate, &detail, &hint); - if (exmsg != NULL) - elog(ERROR, "%s", exmsg); + zval_ptr_dtor(&funcname_zv); + zval_ptr_dtor(&retval); + zend_hash_str_del(CG(function_table), funcname, strlen(funcname)); + + if (exmsg != NULL) + plphp_report_php_error(exmsg, sqlstate, detail, hint); + } } zend_catch { @@ -965,7 +990,8 @@ plphp_inline_handler(PG_FUNCTION_ARGS) strlcpy(str, error_msg, sizeof(str)); pfree(error_msg); error_msg = NULL; - elog(ERROR, "%s", str); + plphp_report_php_error(str, error_sqlstate, + error_detail, error_hint); } else elog(ERROR, "fatal error"); @@ -1183,6 +1209,8 @@ plphp_trig_build_args(FunctionCallInfo fcinfo) add_assoc_string(retval, "event", "DELETE"); else if (TRIGGER_FIRED_BY_UPDATE(tdata->tg_event)) add_assoc_string(retval, "event", "UPDATE"); + else if (TRIGGER_FIRED_BY_TRUNCATE(tdata->tg_event)) + add_assoc_string(retval, "event", "TRUNCATE"); else elog(ERROR, "unknown firing event for trigger function"); @@ -1243,6 +1271,8 @@ plphp_trig_build_args(FunctionCallInfo fcinfo) add_assoc_string(retval, "when", "BEFORE"); else if (TRIGGER_FIRED_AFTER(tdata->tg_event)) add_assoc_string(retval, "when", "AFTER"); + else if (TRIGGER_FIRED_INSTEAD(tdata->tg_event)) + add_assoc_string(retval, "when", "INSTEAD OF"); else elog(ERROR, "unknown firing time for trigger function"); @@ -1298,10 +1328,13 @@ plphp_trigger_handler(FunctionCallInfo fcinfo, plphp_proc_desc *desc) elog(ERROR, "$_TD is not an array"); /* - * In a BEFORE trigger, compute the return value. In an AFTER trigger - * it'll be ignored, so don't bother. + * In a BEFORE or INSTEAD OF trigger, compute the return value: NULL (or + * 'SKIP') suppresses the row, 'MODIFY' substitutes the edited $_TD['new']. + * For INSTEAD OF this is how the row is marked as handled. In an AFTER + * trigger the value is ignored, so don't bother. */ - if (TRIGGER_FIRED_BEFORE(trigdata->tg_event)) + if (TRIGGER_FIRED_BEFORE(trigdata->tg_event) || + TRIGGER_FIRED_INSTEAD(trigdata->tg_event)) { switch (Z_TYPE_P(phpret)) { @@ -1398,15 +1431,21 @@ plphp_event_trigger_handler(FunctionCallInfo fcinfo, plphp_proc_desc *desc) 1, ¶m) == FAILURE) elog(ERROR, "could not call event trigger function %s", desc->proname); - exmsg = plphp_pop_exception_message(); + { + char *sqlstate, + *detail, + *hint; - zval_ptr_dtor(&funcname); - zval_ptr_dtor(¶m); - zval_ptr_dtor(phpret); - efree(phpret); + exmsg = plphp_pop_exception(&sqlstate, &detail, &hint); + + zval_ptr_dtor(&funcname); + zval_ptr_dtor(¶m); + zval_ptr_dtor(phpret); + efree(phpret); - if (exmsg != NULL) - elog(ERROR, "%s", exmsg); + if (exmsg != NULL) + plphp_report_php_error(exmsg, sqlstate, detail, hint); + } /* Disconnect from SPI; the return value of an event trigger is ignored */ if (SPI_finish() != SPI_OK_FINISH) @@ -1883,8 +1922,15 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) prodesc->ret_type |= PL_ARRAY; else { - /* function returns a normal (declared) array */ - if (typlen == -1 && get_element_type(procStruct->prorettype)) + /* + * function returns a normal (declared) array; look through a + * domain so a domain over an array is recognized too. The + * declared return type's input function (result_in_func, set + * above from prorettype) still parses the result, so a domain's + * CHECK constraints are enforced. + */ + if (typlen == -1 && + get_element_type(getBaseType(procStruct->prorettype))) prodesc->ret_type |= PL_ARRAY; } @@ -1979,10 +2025,17 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) /* Main argument processing loop. */ for (i = 0; i < prodesc->n_total_args; i++) { - prodesc->arg_typtype[i] = get_typtype(argtypes[i]); + /* + * Classify through a domain to its base type, so a domain over + * an array or composite is handled like the underlying array + * or composite rather than as an opaque scalar. + */ + Oid argbase = getBaseType(argtypes[i]); + + prodesc->arg_typtype[i] = get_typtype(argbase); if (prodesc->arg_typtype[i] != TYPTYPE_COMPOSITE) { - get_type_io_data(argtypes[i], + get_type_io_data(argbase, IOFunc_output, &typlen, &typbyval, @@ -1993,7 +2046,7 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) perm_fmgr_info(typoutput, &(prodesc->arg_out_func[i]), prodesc->fn_cxt); prodesc->arg_typioparam[i] = typioparam; - prodesc->arg_elemtype[i] = get_element_type(argtypes[i]); + prodesc->arg_elemtype[i] = get_element_type(argbase); } if (aliases && argnames[i][0] != '\0') { @@ -2312,17 +2365,46 @@ message_has_line_suffix(const char *msg) return p != NULL; } +/* Read a non-empty string property off an exception, else NULL (palloc'd). */ static char * -plphp_pop_exception_message(void) +plphp_read_str_property(zend_object *ex, const char *name) +{ + zval rv; + zval *z = zend_read_property(ex->ce, ex, name, strlen(name), 1, &rv); + + if (z != NULL && Z_TYPE_P(z) == IS_STRING && Z_STRLEN_P(z) > 0) + return pstrdup(Z_STRVAL_P(z)); + return NULL; +} + +/* + * Pop the pending PHP exception, returning its message (palloc'd) and, when + * it is a PgError, its SQLSTATE / DETAIL / HINT via the out-parameters (each + * NULL when absent; pass NULL to ignore). Returns NULL if nothing is + * pending. The exception is cleared. + */ +static char * +plphp_pop_exception(char **p_sqlstate, char **p_detail, char **p_hint) { zend_object *ex = EG(exception); zval rv; zval *zmsg; char *msg; + bool is_pgerror; + + if (p_sqlstate != NULL) + *p_sqlstate = NULL; + if (p_detail != NULL) + *p_detail = NULL; + if (p_hint != NULL) + *p_hint = NULL; if (ex == NULL) return NULL; + is_pgerror = (plphp_PgError_ce != NULL && + instanceof_function(ex->ce, plphp_PgError_ce)); + zmsg = zend_read_property(ex->ce, ex, "message", sizeof("message") - 1, 1, &rv); if (zmsg != NULL && Z_TYPE_P(zmsg) == IS_STRING && Z_STRLEN_P(zmsg) > 0) @@ -2331,8 +2413,7 @@ plphp_pop_exception_message(void) * For an uncaught PgError, report " at line " to match * the format database errors have always had in PL/php. */ - if (plphp_PgError_ce != NULL && - instanceof_function(ex->ce, plphp_PgError_ce)) + if (is_pgerror) { zval rvl; zval *zline = zend_read_property(ex->ce, ex, "line", @@ -2356,10 +2437,87 @@ plphp_pop_exception_message(void) else msg = pstrdup("uncaught PHP exception"); + /* Carry the structured fields through, so RAISE ... USING survives. */ + if (is_pgerror) + { + if (p_sqlstate != NULL) + *p_sqlstate = plphp_read_str_property(ex, "sqlstate"); + if (p_detail != NULL) + *p_detail = plphp_read_str_property(ex, "detail"); + if (p_hint != NULL) + *p_hint = plphp_read_str_property(ex, "hint"); + } + zend_clear_exception(); return msg; } +static char * +plphp_pop_exception_message(void) +{ + return plphp_pop_exception(NULL, NULL, NULL); +} + +/* + * Re-raise a popped PHP exception as a PostgreSQL ERROR, preserving the + * PgError's SQLSTATE, DETAIL and HINT when present. A NULL/invalid sqlstate + * falls back to ERRCODE_INTERNAL_ERROR, matching the old elog(ERROR) path + * for plain PHP exceptions. Does not return. + */ +pg_noreturn static void +plphp_report_php_error(char *msg, char *sqlstate, char *detail, char *hint) +{ + int code = (sqlstate != NULL && strlen(sqlstate) == 5) + ? MAKE_SQLSTATE(sqlstate[0], sqlstate[1], sqlstate[2], + sqlstate[3], sqlstate[4]) + : ERRCODE_INTERNAL_ERROR; + + ereport(ERROR, + (errcode(code), + errmsg("%s", msg), + (detail != NULL) ? errdetail("%s", detail) : 0, + (hint != NULL) ? errhint("%s", hint) : 0)); + pg_unreachable(); +} + +/* + * Remember the SQLSTATE / DETAIL / HINT of the PgError that is about to be + * thrown, so an uncaught one can still report them from the zend_catch bridge + * (where the exception object is gone). Stored in TopMemoryContext, freeing + * the previous set; a NULL argument clears that field. Every throw overwrites + * this, so it always reflects the most recently thrown PgError. + */ +void +plphp_stash_error_fields(const char *sqlstate, const char *detail, + const char *hint) +{ + MemoryContext oldcxt = MemoryContextSwitchTo(TopMemoryContext); + + if (error_sqlstate != NULL) + { + pfree(error_sqlstate); + error_sqlstate = NULL; + } + if (error_detail != NULL) + { + pfree(error_detail); + error_detail = NULL; + } + if (error_hint != NULL) + { + pfree(error_hint); + error_hint = NULL; + } + if (sqlstate != NULL && *sqlstate != '\0') + error_sqlstate = pstrdup(sqlstate); + if (detail != NULL) + error_detail = pstrdup(detail); + if (hint != NULL) + error_hint = pstrdup(hint); + + MemoryContextSwitchTo(oldcxt); +} + /* * plphp_call_php_func * Build the function argument array and call the PHP function. @@ -2409,7 +2567,10 @@ plphp_call_php_func(plphp_proc_desc *desc, FunctionCallInfo fcinfo) /* Propagate any uncaught PHP exception as a Postgres error */ { - char *exmsg = plphp_pop_exception_message(); + char *sqlstate, + *detail, + *hint; + char *exmsg = plphp_pop_exception(&sqlstate, &detail, &hint); if (exmsg != NULL) { @@ -2417,7 +2578,7 @@ plphp_call_php_func(plphp_proc_desc *desc, FunctionCallInfo fcinfo) zval_ptr_dtor(¶ms[0]); zval_ptr_dtor(retval); efree(retval); - elog(ERROR, "%s", exmsg); + plphp_report_php_error(exmsg, sqlstate, detail, hint); } } @@ -2468,7 +2629,10 @@ plphp_call_php_trig(plphp_proc_desc *desc, FunctionCallInfo fcinfo, /* Propagate any uncaught PHP exception as a Postgres error */ { - char *exmsg = plphp_pop_exception_message(); + char *sqlstate, + *detail, + *hint; + char *exmsg = plphp_pop_exception(&sqlstate, &detail, &hint); if (exmsg != NULL) { @@ -2476,7 +2640,7 @@ plphp_call_php_trig(plphp_proc_desc *desc, FunctionCallInfo fcinfo, zval_ptr_dtor(&funcname); zval_ptr_dtor(retval); efree(retval); - elog(ERROR, "%s", exmsg); + plphp_report_php_error(exmsg, sqlstate, detail, hint); } } @@ -2508,6 +2672,7 @@ plphp_error_cb(int type, zend_string *error_filename, const char *str = ZSTR_VAL(message); const char *trace; int elevel; + bool from_pgerror; /* * PHP formats an uncaught exception as "...message... in :\n @@ -2523,9 +2688,12 @@ plphp_error_cb(int type, zend_string *error_filename, * An uncaught PgError is just a database (or pg_raise/elog) error that no * PHP code chose to catch: report its original message, not PHP's * "Uncaught PgError: in :" wrapper (the line number is - * re-appended below). + * re-appended below). Its stashed SQLSTATE/DETAIL/HINT (below) still + * apply; for any other fatal they must not, so they are cleared. */ - if (strncmp(str, "Uncaught PgError: ", 18) == 0) + from_pgerror = (strncmp(str, "Uncaught PgError: ", 18) == 0); + + if (from_pgerror) { const char *in = NULL; const char *p = str += 18; @@ -2600,6 +2768,14 @@ plphp_error_cb(int type, zend_string *error_filename, */ if (elevel >= ERROR) { + /* + * The stashed SQLSTATE/DETAIL/HINT belong to a thrown PgError; for any + * other fatal (a PHP error, a validation failure, ...) they are stale + * and must not ride along, so drop them. + */ + if (!from_pgerror) + plphp_stash_error_fields(NULL, NULL, NULL); + if (error_lineno != 0 && !message_has_line_suffix(str)) { char msgline[1024]; diff --git a/plphp_spi.c b/plphp_spi.c index 1bb96fb..3f941ad 100644 --- a/plphp_spi.c +++ b/plphp_spi.c @@ -93,6 +93,9 @@ plphp_throw_pg_error(ErrorData *edata) "hint", strlen("hint"), edata->hint); } + /* Keep the fields for the uncaught (zend_bailout) reporting path. */ + plphp_stash_error_fields(unpack_sql_state(edata->sqlerrcode), + edata->detail, edata->hint); FreeErrorData(edata); } @@ -124,6 +127,9 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_pg_raise, 0, 0, 2) ZEND_ARG_INFO(0, level) ZEND_ARG_INFO(0, message) + ZEND_ARG_INFO(0, detail) + ZEND_ARG_INFO(0, hint) + ZEND_ARG_INFO(0, sqlstate) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_return_next, 0, 0, 0) @@ -439,46 +445,102 @@ ZEND_FUNCTION(spi_rewind) /* * pg_raise * User-callable function for sending messages to the Postgres log. + * + * pg_raise(level, message [, detail [, hint [, sqlstate]]]). The optional + * detail, hint and (for ERROR) sqlstate mirror PL/pgSQL's RAISE ... USING: + * an ERROR is thrown as a catchable PgError carrying all three, and a + * WARNING/NOTICE is reported with DETAIL/HINT attached. */ ZEND_FUNCTION(pg_raise) { char *level = NULL, - *message = NULL; + *message = NULL, + *detail = NULL, + *hint = NULL, + *sqlstate = NULL; size_t level_len, - message_len; + message_len, + detail_len = 0, + hint_len = 0, + sqlstate_len = 0; int elevel = 0; - if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss", + if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss|s!s!s!", &level, &level_len, - &message, &message_len) == FAILURE) + &message, &message_len, + &detail, &detail_len, + &hint, &hint_len, + &sqlstate, &sqlstate_len) == FAILURE) { zend_error(E_WARNING, "cannot parse parameters in %s", get_active_function_name()); return; } + /* An explicit SQLSTATE must be five [0-9A-Z] characters, like RAISE. */ + if (sqlstate != NULL) + { + size_t i; + + if (sqlstate_len != 5) + { + zend_error(E_ERROR, "pg_raise: SQLSTATE must be exactly five characters"); + return; + } + for (i = 0; i < 5; i++) + if (!((sqlstate[i] >= '0' && sqlstate[i] <= '9') || + (sqlstate[i] >= 'A' && sqlstate[i] <= 'Z'))) + { + zend_error(E_ERROR, "pg_raise: SQLSTATE must contain only digits and upper-case ASCII letters"); + return; + } + } + if (strcasecmp(level, "ERROR") == 0) { - /* raise a catchable PgError, like PL/pgSQL's RAISE (SQLSTATE P0001) */ + /* raise a catchable PgError, like PL/pgSQL's RAISE ... USING */ zend_object *ex = zend_throw_exception(plphp_PgError_ce, message, 0); if (ex != NULL) + { zend_update_property_string(plphp_PgError_ce, ex, "sqlstate", strlen("sqlstate"), - "P0001"); + sqlstate != NULL ? sqlstate : "P0001"); + if (detail != NULL) + zend_update_property_string(plphp_PgError_ce, ex, + "detail", strlen("detail"), detail); + if (hint != NULL) + zend_update_property_string(plphp_PgError_ce, ex, + "hint", strlen("hint"), hint); + } + /* Keep the fields for the uncaught (zend_bailout) reporting path. */ + plphp_stash_error_fields(sqlstate != NULL ? sqlstate : "P0001", + detail, hint); return; } else if (strcasecmp(level, "WARNING") == 0) - elevel = E_WARNING; + elevel = WARNING; else if (strcasecmp(level, "NOTICE") == 0) - elevel = E_NOTICE; + elevel = NOTICE; else { zend_error(E_ERROR, "incorrect log level"); return; } - zend_error(elevel, "%s", message); + /* + * With no DETAIL/HINT this is the historical case: route through + * zend_error so the message formatting is byte-for-byte unchanged. Only + * when the caller supplies extra fields do we ereport directly to carry + * them. + */ + if (detail == NULL && hint == NULL) + zend_error(elevel == WARNING ? E_WARNING : E_NOTICE, "%s", message); + else + ereport(elevel, + (errmsg("%s", message), + (detail != NULL) ? errdetail("%s", detail) : 0, + (hint != NULL) ? errhint("%s", hint) : 0)); } /* @@ -1424,6 +1486,8 @@ ZEND_FUNCTION(elog) zend_update_property_string(plphp_PgError_ce, ex, "sqlstate", strlen("sqlstate"), "P0001"); + /* Keep the fields for the uncaught (zend_bailout) reporting path. */ + plphp_stash_error_fields("P0001", NULL, NULL); } else ereport(elevel, (errmsg("%s", message))); diff --git a/plphp_spi.h b/plphp_spi.h index 14c82de..9397fe7 100644 --- a/plphp_spi.h +++ b/plphp_spi.h @@ -73,6 +73,8 @@ ZEND_FUNCTION(subtransaction); void php_SPIresult_destroy(zend_resource *rsrc); void php_SPIplan_destroy(zend_resource *rsrc); void plphp_throw_pg_error(ErrorData *edata); +void plphp_stash_error_fields(const char *sqlstate, const char *detail, + const char *hint); #endif /* PLPHP_SPI_H */ diff --git a/sql/domains.sql b/sql/domains.sql new file mode 100644 index 0000000..7ba73ae --- /dev/null +++ b/sql/domains.sql @@ -0,0 +1,50 @@ +-- +-- Domain types. A PL/php function sees a domain as its underlying base type +-- (scalar, array or composite), and a value returned into a domain result is +-- checked against the domain's constraints. +-- +CREATE DOMAIN posint AS int CHECK (VALUE > 0); +CREATE DOMAIN shortstr AS text CHECK (length(VALUE) <= 5); +CREATE DOMAIN intvec AS int[] CHECK (cardinality(VALUE) > 0); + +-- A scalar domain argument arrives as the base value; the CHECK on the input +-- has already been enforced by the caller. +CREATE FUNCTION dom_scalar(posint, shortstr) RETURNS text LANGUAGE plphp AS $$ + return $args[0] . "/" . strtoupper($args[1]); +$$; +SELECT dom_scalar(7, 'abc'); + +-- Returning into a scalar domain enforces its CHECK. +CREATE FUNCTION dom_double(int) RETURNS posint LANGUAGE plphp AS $$ + return $args[0] * 2; +$$; +SELECT dom_double(21); +SELECT dom_double(-1); -- violates posint + +-- A domain over an array arrives as a PHP array, and a PHP array can be +-- returned into it (the domain CHECK still applies). +CREATE FUNCTION dom_arr_in(intvec) RETURNS int LANGUAGE plphp AS $$ + return is_array($args[0]) ? array_sum($args[0]) : -1; +$$; +SELECT dom_arr_in('{10,20,30}'); + +CREATE FUNCTION dom_arr_out(int) RETURNS intvec LANGUAGE plphp AS $$ + $out = array(); + for ($i = 1; $i <= $args[0]; $i++) + $out[] = $i * $i; + return $out; +$$; +SELECT dom_arr_out(4); +SELECT dom_arr_out(0); -- empty array violates intvec's CHECK + +-- A domain used as a composite field is unwrapped there too. +CREATE TYPE drow AS (id posint, tag shortstr); +CREATE FUNCTION dom_field(drow) RETURNS text LANGUAGE plphp AS $$ + return $args[0]['id'] . ":" . $args[0]['tag']; +$$; +SELECT dom_field(ROW(3, 'ok')::drow); + +DROP FUNCTION dom_scalar(posint, shortstr), dom_double(int), + dom_arr_in(intvec), dom_arr_out(int), dom_field(drow); +DROP TYPE drow; +DROP DOMAIN posint, shortstr, intvec; diff --git a/sql/pgerror.sql b/sql/pgerror.sql index 4ef9a97..297eb6a 100644 --- a/sql/pgerror.sql +++ b/sql/pgerror.sql @@ -105,5 +105,53 @@ CREATE FUNCTION err_outer_catch() RETURNS text LANGUAGE plphp AS $$ $$; SELECT err_outer_catch(); +-- pg_raise can attach DETAIL, HINT and a custom SQLSTATE, like PL/pgSQL's +-- RAISE ... USING. Caught, all four fields are readable. +CREATE FUNCTION err_raise_using() RETURNS text LANGUAGE plphp AS $$ + try { + pg_raise('error', 'bad thing', 'because reasons', 'do X', '22023'); + } catch (PgError $e) { + return sprintf("[%s] %s / detail=%s / hint=%s", + $e->getSQLState(), $e->getMessage(), $e->getDetail(), $e->getHint()); + } +$$; +SELECT err_raise_using(); + +-- The custom SQLSTATE/detail/hint survive an uncaught trip out through the +-- PostgreSQL error layer and back into a PgError caught one call up. +CREATE FUNCTION err_custom_inner() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'custom failure', 'the gory details', 'try harder', '22012'); +$$; +CREATE FUNCTION err_custom_outer() RETURNS text LANGUAGE plphp AS $$ + try { + spi_exec("select err_custom_inner()"); + } catch (PgError $e) { + return $e->getSQLState() . " | " . $e->getDetail() . " | " . $e->getHint(); + } + return "not reached"; +$$; +SELECT err_custom_outer(); + +-- Uncaught, the DETAIL and HINT lines show up in the error itself (psql +-- prints them at default verbosity). +CREATE FUNCTION err_uncaught_using() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'boom', 'what went wrong', 'what to do'); +$$; +SELECT err_uncaught_using(); + +-- A NOTICE can carry DETAIL/HINT too. +CREATE FUNCTION note_using() RETURNS void LANGUAGE plphp AS $$ + pg_raise('notice', 'heads up', 'more info', 'a suggestion'); +$$; +SELECT note_using(); + +-- An invalid SQLSTATE (not five upper-case/digit characters) is rejected. +CREATE FUNCTION err_bad_sqlstate() RETURNS void LANGUAGE plphp AS $$ + pg_raise('error', 'x', null, null, 'abcde'); +$$; +SELECT err_bad_sqlstate(); + +DROP FUNCTION err_raise_using(), err_custom_inner(), err_custom_outer(), + err_uncaught_using(), note_using(), err_bad_sqlstate(); DROP FUNCTION err_inner(), err_outer(), err_outer_catch(); DROP TABLE errt; diff --git a/sql/trigger2.sql b/sql/trigger2.sql new file mode 100644 index 0000000..e618961 --- /dev/null +++ b/sql/trigger2.sql @@ -0,0 +1,60 @@ +-- +-- Trigger coverage beyond BEFORE/AFTER INSERT/UPDATE/DELETE rows: +-- statement-level TRUNCATE triggers, INSTEAD OF view triggers, and WHEN(...). +-- + +-- A statement-level TRUNCATE trigger fires with $_TD['event'] = 'TRUNCATE'. +CREATE TABLE trunc_t (a int); +INSERT INTO trunc_t VALUES (1), (2), (3); +CREATE FUNCTION trunc_trig() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "truncate trigger: when={$_TD['when']} event={$_TD['event']} level={$_TD['level']}"); + return null; +$$; +CREATE TRIGGER trunc_before BEFORE TRUNCATE ON trunc_t + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig(); +CREATE TRIGGER trunc_after AFTER TRUNCATE ON trunc_t + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig(); +TRUNCATE trunc_t; +SELECT count(*) AS rows_after_truncate FROM trunc_t; +DROP TABLE trunc_t; + +-- INSTEAD OF triggers on a view: the trigger does the real work against the +-- base table and returns (non-NULL) to mark the row handled. 'when' is +-- 'INSTEAD OF'. +CREATE TABLE base_t (id int PRIMARY KEY, val text); +CREATE VIEW base_v AS SELECT id, val FROM base_t; +CREATE FUNCTION base_v_ins() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "INSTEAD OF {$_TD['event']} (when={$_TD['when']}, level={$_TD['level']})"); + spi_exec("insert into base_t values (" . intval($_TD['new']['id']) . ", " . + quote_literal(strtoupper($_TD['new']['val'])) . ")"); + return null; /* row handled */ +$$; +CREATE FUNCTION base_v_del() RETURNS trigger LANGUAGE plphp AS $$ + spi_exec("delete from base_t where id = " . intval($_TD['old']['id'])); + return null; +$$; +CREATE TRIGGER base_v_ins_t INSTEAD OF INSERT ON base_v + FOR EACH ROW EXECUTE PROCEDURE base_v_ins(); +CREATE TRIGGER base_v_del_t INSTEAD OF DELETE ON base_v + FOR EACH ROW EXECUTE PROCEDURE base_v_del(); +INSERT INTO base_v VALUES (1, 'hello'), (2, 'world'); +SELECT * FROM base_t ORDER BY id; +DELETE FROM base_v WHERE id = 1; +SELECT * FROM base_t ORDER BY id; +DROP VIEW base_v; +DROP TABLE base_t; + +-- WHEN(...) gates whether the trigger fires at all (evaluated by PostgreSQL, +-- so the function only runs for matching rows). +CREATE TABLE when_t (a int, b text); +CREATE FUNCTION when_trig() RETURNS trigger LANGUAGE plphp AS $$ + elog('NOTICE', "fired for a={$_TD['new']['a']}"); + return null; +$$; +CREATE TRIGGER when_big BEFORE INSERT ON when_t + FOR EACH ROW WHEN (NEW.a > 10) EXECUTE PROCEDURE when_trig(); +INSERT INTO when_t VALUES (5, 'small'), (20, 'big'), (30, 'bigger'); +SELECT count(*) AS inserted FROM when_t; +DROP TABLE when_t; + +DROP FUNCTION trunc_trig(), base_v_ins(), base_v_del(), when_trig();