Skip to content

Commit c46c17a

Browse files
jdatcmdOffgridwithJDclaude
authored
Add hstore transform, structured pg_raise, TRUNCATE/INSTEAD OF triggers, domain array/composite support (#29)
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: Joshua (D) Drake <136637981+ChronicallyJD@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b6aa81a commit c46c17a

19 files changed

Lines changed: 1310 additions & 50 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
2020

2121
### Added
2222

23+
- **`hstore` transform (`hstore_plphp`).** A new `CREATE EXTENSION hstore_plphp`
24+
adds `TRANSFORM FOR TYPE hstore`, mapping an `hstore` to a PHP associative
25+
array of string keys to string-or-null values and back (a PHP `null` value
26+
becomes an hstore `NULL`). Companion to the existing `jsonb_plphp`.
27+
- **Structured `pg_raise`.** `pg_raise(level, message [, detail [, hint [,
28+
sqlstate]]])` now attaches `DETAIL`, `HINT` and (for `ERROR`) a custom
29+
`SQLSTATE`, mirroring PL/pgSQL's `RAISE ... USING`. The fields are readable
30+
on a caught `PgError` and survive an uncaught error out to the client.
31+
- **`TRUNCATE` and `INSTEAD OF` triggers.** Statement-level `TRUNCATE` triggers
32+
(`$_TD['event'] = 'TRUNCATE'`) and view `INSTEAD OF` triggers
33+
(`$_TD['when'] = 'INSTEAD OF'`) are now supported; previously the handler
34+
errored on the unrecognized event/timing.
35+
- **Domains over arrays and composites.** A function argument or result typed
36+
as a domain is now handled as its base type, so a domain over an array
37+
arrives as a PHP array (and can be returned as one) and a domain over a
38+
composite as an associative array. The domain's `CHECK` constraints are
39+
still enforced on results. Scalar domains were already transparent.
2340
- **Project logo and brand assets.** Light/dark PL/php logos and a square icon
2441
under `doc/assets/`, wired into the README and language reference.
2542
- **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/).
2845

2946
### Fixed
3047

48+
- **Uncaught database errors lost their SQLSTATE, DETAIL and HINT.** An error
49+
that unwound to the top of a PL/php call was reported with only its message
50+
(and `SQLSTATE XX000`); it now carries the original `SQLSTATE`, `DETAIL` and
51+
`HINT` through the `zend_bailout` reporting path.
3152
- **Backend crash when an error crossed nested PL/php calls.** A PostgreSQL
3253
error unwinding out of a handler's `zend_try` left Zend's bailout
3354
environment pointing into a dead stack frame; the next uncaught error then

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ PG_CFLAGS += $(ASAN_FLAGS)
4141
SHLIB_LINK = $(ASAN_FLAGS) -L$(PHP_LIBDIR) -l$(PHP_LIBNAME) $(shell $(PHP_CONFIG) --ldflags)
4242

4343
# Regression tests. "init" installs the extension; keep it first.
44-
REGRESS = init base shared trigger spi raise cargs pseudo srf out varnames validator compat txn evttrig subxact modules oninit cursor arrays coverage cookbook pgerror
44+
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
4545

4646
PG_CONFIG ?= pg_config
4747
PGXS := $(shell $(PG_CONFIG) --pgxs)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ SELECT hello('world'); -- Hello, world!
5454
| 🔐 **Transaction control** | `spi_commit` / `spi_rollback` in procedures, plus `subtransaction()` blocks. |
5555
| 🧰 **Utilities** | `quote_literal` / `quote_nullable` / `quote_ident`, `elog`, `$_SHARED`. |
5656
| 📦 **Session setup** | Anonymous `DO` blocks, `plphp_modules` autoloading, `plphp.on_init`, and a `plphp.start_proc` hook. |
57-
| 🧬 **Native jsonb** | The `jsonb_plphp` transform: `TRANSFORM FOR TYPE jsonb` maps jsonb ⇄ PHP arrays directly. |
57+
| 🧬 **Native jsonb & hstore** | The `jsonb_plphp` and `hstore_plphp` transforms map `jsonb` and `hstore` ⇄ PHP arrays directly. |
5858

5959
See the [**language reference**](doc/plphp.md) for the full API, the
6060
[**cookbook**](doc/cookbook.md) for practical, regression-tested recipes, and

doc/plphp.md

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,38 @@ distinguish an empty list from an empty map, the same ambiguity
141141
`json_encode` has); JSON numbers arrive as PHP int when they fit, else float
142142
(precision beyond a double is lost).
143143

144+
### Native hstore via the `hstore_plphp` transform
145+
146+
The companion `hstore_plphp` extension does for `hstore` what `jsonb_plphp`
147+
does for `jsonb`: with `TRANSFORM FOR TYPE hstore`, an `hstore` argument
148+
arrives as a PHP associative array of string keys to string-or-`null` values,
149+
and a returned PHP array is converted back. A PHP `null` value becomes an
150+
hstore `NULL`; keys and scalar values are stringified.
151+
152+
```sql
153+
CREATE EXTENSION hstore_plphp CASCADE; -- pulls in hstore and plphp
154+
155+
CREATE FUNCTION tags_upper(hstore) RETURNS hstore
156+
LANGUAGE plphp TRANSFORM FOR TYPE hstore AS $$
157+
$out = array();
158+
foreach ($args[0] as $k => $v)
159+
$out[strtoupper($k)] = is_null($v) ? null : strtoupper($v);
160+
return $out;
161+
$$;
162+
SELECT tags_upper('a=>x, b=>NULL'); -- "A"=>"X", "B"=>NULL
163+
```
164+
165+
Returning something other than an array, or a nested array as a value, is
166+
rejected with a clear error (hstore values are text or null).
167+
168+
### Domains
169+
170+
A domain is handled as its underlying base type: a scalar domain behaves like
171+
its base scalar, a domain over an array arrives as a PHP array (and can be
172+
returned as one), and a domain over a composite as an associative array. The
173+
domain's `CHECK` constraints are enforced on a returned value, just as
174+
PostgreSQL enforces them elsewhere.
175+
144176
## Composite types and records
145177

146178
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`:
249281
| `$_TD['relid']` | table OID |
250282
| `$_TD['relname']` | table name |
251283
| `$_TD['schemaname']` | schema name |
252-
| `$_TD['when']` | `BEFORE` or `AFTER` |
284+
| `$_TD['when']` | `BEFORE`, `AFTER`, or `INSTEAD OF` |
253285
| `$_TD['level']` | `ROW` or `STATEMENT` |
254-
| `$_TD['event']` | `INSERT`, `UPDATE`, or `DELETE` |
286+
| `$_TD['event']` | `INSERT`, `UPDATE`, `DELETE`, or `TRUNCATE` |
255287
| `$_TD['new']` | new row (INSERT/UPDATE), as an associative array |
256288
| `$_TD['old']` | old row (UPDATE/DELETE), as an associative array |
257289
| `$_TD['argc']` | number of trigger arguments |
@@ -271,6 +303,13 @@ CREATE FUNCTION uppercase_name() RETURNS trigger LANGUAGE plphp AS $$
271303
$$;
272304
```
273305

306+
The same return convention drives **INSTEAD OF ... FOR EACH ROW** triggers on
307+
views: the trigger does the real work (typically against a base table) and
308+
returns `;`/`'MODIFY'` to mark the row handled, or `'SKIP'` to skip it. A
309+
statement-level **TRUNCATE** trigger fires with `$_TD['event'] = 'TRUNCATE'`
310+
and no row; `WHEN (...)` conditions are evaluated by PostgreSQL, so the
311+
function only runs for matching rows.
312+
274313
## Event trigger functions
275314

276315
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
460499
```
461500

462501
The level is one of `DEBUG`, `LOG`, `INFO`, `NOTICE`, `WARNING`, or `ERROR`
463-
(case-insensitive). `pg_raise(level, message)` is an older, narrower spelling
464-
that accepts `notice`, `warning`, or `error`. Anything PHP writes to standard
465-
output is also forwarded to the PostgreSQL log.
502+
(case-insensitive). Anything PHP writes to standard output is also forwarded to
503+
the PostgreSQL log.
504+
505+
`pg_raise(level, message [, detail [, hint [, sqlstate]]])` accepts `notice`,
506+
`warning`, or `error`, and optionally attaches a `DETAIL`, a `HINT`, and (for
507+
`error`) a custom five-character `SQLSTATE` — the equivalent of PL/pgSQL's
508+
`RAISE ... USING`:
509+
510+
```php
511+
pg_raise('error', 'balance would go negative',
512+
'account 42 has 10, tried to withdraw 15', // DETAIL
513+
'deposit first, or withdraw less', // HINT
514+
'22003'); // SQLSTATE
515+
```
516+
517+
These fields are readable on a caught `PgError` (`getSQLState()`, `getDetail()`,
518+
`getHint()`) and, when the error is left uncaught, appear on the error reported
519+
to the client.
466520

467521
## Shared data: `$_SHARED`
468522

expected/domains.out

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
--
2+
-- Domain types. A PL/php function sees a domain as its underlying base type
3+
-- (scalar, array or composite), and a value returned into a domain result is
4+
-- checked against the domain's constraints.
5+
--
6+
CREATE DOMAIN posint AS int CHECK (VALUE > 0);
7+
CREATE DOMAIN shortstr AS text CHECK (length(VALUE) <= 5);
8+
CREATE DOMAIN intvec AS int[] CHECK (cardinality(VALUE) > 0);
9+
-- A scalar domain argument arrives as the base value; the CHECK on the input
10+
-- has already been enforced by the caller.
11+
CREATE FUNCTION dom_scalar(posint, shortstr) RETURNS text LANGUAGE plphp AS $$
12+
return $args[0] . "/" . strtoupper($args[1]);
13+
$$;
14+
SELECT dom_scalar(7, 'abc');
15+
dom_scalar
16+
------------
17+
7/ABC
18+
(1 row)
19+
20+
-- Returning into a scalar domain enforces its CHECK.
21+
CREATE FUNCTION dom_double(int) RETURNS posint LANGUAGE plphp AS $$
22+
return $args[0] * 2;
23+
$$;
24+
SELECT dom_double(21);
25+
dom_double
26+
------------
27+
42
28+
(1 row)
29+
30+
SELECT dom_double(-1); -- violates posint
31+
ERROR: value for domain posint violates check constraint "posint_check"
32+
CONTEXT: PL/php function "dom_double"
33+
-- A domain over an array arrives as a PHP array, and a PHP array can be
34+
-- returned into it (the domain CHECK still applies).
35+
CREATE FUNCTION dom_arr_in(intvec) RETURNS int LANGUAGE plphp AS $$
36+
return is_array($args[0]) ? array_sum($args[0]) : -1;
37+
$$;
38+
SELECT dom_arr_in('{10,20,30}');
39+
dom_arr_in
40+
------------
41+
60
42+
(1 row)
43+
44+
CREATE FUNCTION dom_arr_out(int) RETURNS intvec LANGUAGE plphp AS $$
45+
$out = array();
46+
for ($i = 1; $i <= $args[0]; $i++)
47+
$out[] = $i * $i;
48+
return $out;
49+
$$;
50+
SELECT dom_arr_out(4);
51+
dom_arr_out
52+
-------------
53+
{1,4,9,16}
54+
(1 row)
55+
56+
SELECT dom_arr_out(0); -- empty array violates intvec's CHECK
57+
ERROR: value for domain intvec violates check constraint "intvec_check"
58+
CONTEXT: PL/php function "dom_arr_out"
59+
-- A domain used as a composite field is unwrapped there too.
60+
CREATE TYPE drow AS (id posint, tag shortstr);
61+
CREATE FUNCTION dom_field(drow) RETURNS text LANGUAGE plphp AS $$
62+
return $args[0]['id'] . ":" . $args[0]['tag'];
63+
$$;
64+
SELECT dom_field(ROW(3, 'ok')::drow);
65+
dom_field
66+
-----------
67+
3:ok
68+
(1 row)
69+
70+
DROP FUNCTION dom_scalar(posint, shortstr), dom_double(int),
71+
dom_arr_in(intvec), dom_arr_out(int), dom_field(drow);
72+
DROP TYPE drow;
73+
DROP DOMAIN posint, shortstr, intvec;

expected/pgerror.out

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,5 +142,72 @@ SELECT err_outer_catch();
142142
caught nested: division by zero at line 2
143143
(1 row)
144144

145+
-- pg_raise can attach DETAIL, HINT and a custom SQLSTATE, like PL/pgSQL's
146+
-- RAISE ... USING. Caught, all four fields are readable.
147+
CREATE FUNCTION err_raise_using() RETURNS text LANGUAGE plphp AS $$
148+
try {
149+
pg_raise('error', 'bad thing', 'because reasons', 'do X', '22023');
150+
} catch (PgError $e) {
151+
return sprintf("[%s] %s / detail=%s / hint=%s",
152+
$e->getSQLState(), $e->getMessage(), $e->getDetail(), $e->getHint());
153+
}
154+
$$;
155+
SELECT err_raise_using();
156+
err_raise_using
157+
--------------------------------------------------------
158+
[22023] bad thing / detail=because reasons / hint=do X
159+
(1 row)
160+
161+
-- The custom SQLSTATE/detail/hint survive an uncaught trip out through the
162+
-- PostgreSQL error layer and back into a PgError caught one call up.
163+
CREATE FUNCTION err_custom_inner() RETURNS void LANGUAGE plphp AS $$
164+
pg_raise('error', 'custom failure', 'the gory details', 'try harder', '22012');
165+
$$;
166+
CREATE FUNCTION err_custom_outer() RETURNS text LANGUAGE plphp AS $$
167+
try {
168+
spi_exec("select err_custom_inner()");
169+
} catch (PgError $e) {
170+
return $e->getSQLState() . " | " . $e->getDetail() . " | " . $e->getHint();
171+
}
172+
return "not reached";
173+
$$;
174+
SELECT err_custom_outer();
175+
err_custom_outer
176+
---------------------------------------
177+
22012 | the gory details | try harder
178+
(1 row)
179+
180+
-- Uncaught, the DETAIL and HINT lines show up in the error itself (psql
181+
-- prints them at default verbosity).
182+
CREATE FUNCTION err_uncaught_using() RETURNS void LANGUAGE plphp AS $$
183+
pg_raise('error', 'boom', 'what went wrong', 'what to do');
184+
$$;
185+
SELECT err_uncaught_using();
186+
ERROR: boom at line 2
187+
DETAIL: what went wrong
188+
HINT: what to do
189+
CONTEXT: PL/php function "err_uncaught_using"
190+
-- A NOTICE can carry DETAIL/HINT too.
191+
CREATE FUNCTION note_using() RETURNS void LANGUAGE plphp AS $$
192+
pg_raise('notice', 'heads up', 'more info', 'a suggestion');
193+
$$;
194+
SELECT note_using();
195+
NOTICE: heads up
196+
DETAIL: more info
197+
HINT: a suggestion
198+
note_using
199+
------------
200+
201+
(1 row)
202+
203+
-- An invalid SQLSTATE (not five upper-case/digit characters) is rejected.
204+
CREATE FUNCTION err_bad_sqlstate() RETURNS void LANGUAGE plphp AS $$
205+
pg_raise('error', 'x', null, null, 'abcde');
206+
$$;
207+
SELECT err_bad_sqlstate();
208+
ERROR: pg_raise: SQLSTATE must contain only digits and upper-case ASCII letters at line 2
209+
CONTEXT: PL/php function "err_bad_sqlstate"
210+
DROP FUNCTION err_raise_using(), err_custom_inner(), err_custom_outer(),
211+
err_uncaught_using(), note_using(), err_bad_sqlstate();
145212
DROP FUNCTION err_inner(), err_outer(), err_outer_catch();
146213
DROP TABLE errt;

expected/trigger2.out

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
--
2+
-- Trigger coverage beyond BEFORE/AFTER INSERT/UPDATE/DELETE rows:
3+
-- statement-level TRUNCATE triggers, INSTEAD OF view triggers, and WHEN(...).
4+
--
5+
-- A statement-level TRUNCATE trigger fires with $_TD['event'] = 'TRUNCATE'.
6+
CREATE TABLE trunc_t (a int);
7+
INSERT INTO trunc_t VALUES (1), (2), (3);
8+
CREATE FUNCTION trunc_trig() RETURNS trigger LANGUAGE plphp AS $$
9+
elog('NOTICE', "truncate trigger: when={$_TD['when']} event={$_TD['event']} level={$_TD['level']}");
10+
return null;
11+
$$;
12+
CREATE TRIGGER trunc_before BEFORE TRUNCATE ON trunc_t
13+
FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig();
14+
CREATE TRIGGER trunc_after AFTER TRUNCATE ON trunc_t
15+
FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig();
16+
TRUNCATE trunc_t;
17+
NOTICE: truncate trigger: when=BEFORE event=TRUNCATE level=STATEMENT
18+
NOTICE: truncate trigger: when=AFTER event=TRUNCATE level=STATEMENT
19+
SELECT count(*) AS rows_after_truncate FROM trunc_t;
20+
rows_after_truncate
21+
---------------------
22+
0
23+
(1 row)
24+
25+
DROP TABLE trunc_t;
26+
-- INSTEAD OF triggers on a view: the trigger does the real work against the
27+
-- base table and returns (non-NULL) to mark the row handled. 'when' is
28+
-- 'INSTEAD OF'.
29+
CREATE TABLE base_t (id int PRIMARY KEY, val text);
30+
CREATE VIEW base_v AS SELECT id, val FROM base_t;
31+
CREATE FUNCTION base_v_ins() RETURNS trigger LANGUAGE plphp AS $$
32+
elog('NOTICE', "INSTEAD OF {$_TD['event']} (when={$_TD['when']}, level={$_TD['level']})");
33+
spi_exec("insert into base_t values (" . intval($_TD['new']['id']) . ", " .
34+
quote_literal(strtoupper($_TD['new']['val'])) . ")");
35+
return null; /* row handled */
36+
$$;
37+
CREATE FUNCTION base_v_del() RETURNS trigger LANGUAGE plphp AS $$
38+
spi_exec("delete from base_t where id = " . intval($_TD['old']['id']));
39+
return null;
40+
$$;
41+
CREATE TRIGGER base_v_ins_t INSTEAD OF INSERT ON base_v
42+
FOR EACH ROW EXECUTE PROCEDURE base_v_ins();
43+
CREATE TRIGGER base_v_del_t INSTEAD OF DELETE ON base_v
44+
FOR EACH ROW EXECUTE PROCEDURE base_v_del();
45+
INSERT INTO base_v VALUES (1, 'hello'), (2, 'world');
46+
NOTICE: INSTEAD OF INSERT (when=INSTEAD OF, level=ROW)
47+
NOTICE: INSTEAD OF INSERT (when=INSTEAD OF, level=ROW)
48+
SELECT * FROM base_t ORDER BY id;
49+
id | val
50+
----+-------
51+
1 | HELLO
52+
2 | WORLD
53+
(2 rows)
54+
55+
DELETE FROM base_v WHERE id = 1;
56+
SELECT * FROM base_t ORDER BY id;
57+
id | val
58+
----+-------
59+
2 | WORLD
60+
(1 row)
61+
62+
DROP VIEW base_v;
63+
DROP TABLE base_t;
64+
-- WHEN(...) gates whether the trigger fires at all (evaluated by PostgreSQL,
65+
-- so the function only runs for matching rows).
66+
CREATE TABLE when_t (a int, b text);
67+
CREATE FUNCTION when_trig() RETURNS trigger LANGUAGE plphp AS $$
68+
elog('NOTICE', "fired for a={$_TD['new']['a']}");
69+
return null;
70+
$$;
71+
CREATE TRIGGER when_big BEFORE INSERT ON when_t
72+
FOR EACH ROW WHEN (NEW.a > 10) EXECUTE PROCEDURE when_trig();
73+
INSERT INTO when_t VALUES (5, 'small'), (20, 'big'), (30, 'bigger');
74+
NOTICE: fired for a=20
75+
NOTICE: fired for a=30
76+
SELECT count(*) AS inserted FROM when_t;
77+
inserted
78+
----------
79+
3
80+
(1 row)
81+
82+
DROP TABLE when_t;
83+
DROP FUNCTION trunc_trig(), base_v_ins(), base_v_del(), when_trig();

0 commit comments

Comments
 (0)