Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 59 additions & 5 deletions doc/plphp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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`

Expand Down
73 changes: 73 additions & 0 deletions expected/domains.out
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 67 additions & 0 deletions expected/pgerror.out
Original file line number Diff line number Diff line change
Expand Up @@ -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;
83 changes: 83 additions & 0 deletions expected/trigger2.out
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading