diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c5cb56..2c06935 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,15 @@ jobs: PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config \ PHP_CONFIG=php-config${{ matrix.php }} install + - name: Build and install bytea_plphp + run: | + make -C bytea_plphp \ + PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config \ + PHP_CONFIG=php-config${{ matrix.php }} + sudo make -C bytea_plphp \ + PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config \ + PHP_CONFIG=php-config${{ matrix.php }} install + - name: Start the cluster run: | # the runner image disables automatic cluster creation @@ -83,11 +92,19 @@ jobs: make -C jsonb_plphp \ PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config installcheck + - name: Run the bytea_plphp regression tests + run: | + port=$(pg_lsclusters | awk '$1 == "${{ matrix.pg }}" && $2 == "main" { print $3 }') + sudo -u postgres env PGPORT=$port \ + make -C bytea_plphp \ + PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config installcheck + - name: Show regression diffs on failure if: failure() run: | cat regression.diffs 2>/dev/null || true cat jsonb_plphp/regression.diffs 2>/dev/null || true + cat bytea_plphp/regression.diffs 2>/dev/null || true asan: name: ASAN (PG 18 / PHP 8.3) @@ -119,6 +136,10 @@ jobs: ASAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer" sudo make -C jsonb_plphp PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config PHP_CONFIG=php-config8.3 \ ASAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer" install + make -C bytea_plphp PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config PHP_CONFIG=php-config8.3 \ + ASAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer" + sudo make -C bytea_plphp PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config PHP_CONFIG=php-config8.3 \ + ASAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer" install - name: Start the cluster with libasan preloaded run: | @@ -146,6 +167,9 @@ jobs: sudo -u postgres env PGPORT=$port \ make -C jsonb_plphp \ PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config installcheck + sudo -u postgres env PGPORT=$port \ + make -C bytea_plphp \ + PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config installcheck - name: Show ASAN reports and diffs on failure if: failure() @@ -154,3 +178,4 @@ jobs: sudo tail -50 /tmp/pg-asan.log 2>/dev/null || true cat regression.diffs 2>/dev/null || true cat jsonb_plphp/regression.diffs 2>/dev/null || true + cat bytea_plphp/regression.diffs 2>/dev/null || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 523599a..14359b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to PL/php are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to follow [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Private per-function data: `$_SD`.** Each function now has an `$_SD` + associative array that persists across calls to that function within a + session and is private to it, the per-function counterpart to the + session-global `$_SHARED`. The pair mirrors PL/Python's `SD` and `GD`. `$_SD` + starts empty and is reset when the function is redefined. Typical use is + caching a prepared plan once per session. +- **`bytea` transform (`bytea_plphp`).** A new `CREATE EXTENSION bytea_plphp` + adds `TRANSFORM FOR TYPE bytea`, mapping a `bytea` to a raw, binary-safe PHP + string (the bytes themselves, not the `\x...` text form) and back, mirroring + PL/Python's `bytea` <-> `bytes`. Embedded NUL bytes survive the round trip, + which the default text path truncates. Applies in nested contexts like the + other transforms. + ## [2.5.0] - 2026-07-06 ### Added diff --git a/Makefile b/Makefile index 13c4c77..2930d32 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 trigger2 spi raise cargs pseudo srf out varnames validator compat txn evttrig subxact modules oninit cursor arrays domains coverage cookbook pgerror +REGRESS = init base shared sd 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 f568c95..2dc3e29 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ SELECT hello('world'); -- Hello, world! | 🗄️ **Database access (SPI)** | `spi_exec`, `spi_fetch_row`, `spi_processed`, `spi_status`, `spi_rewind`. | | 📝 **Prepared statements** | `spi_prepare` / `spi_exec_prepared` / `spi_query_prepared` / `spi_freeplan`. | | 🔐 **Transaction control** | `spi_commit` / `spi_rollback` in procedures, plus `subtransaction()` blocks. | -| 🧰 **Utilities** | `quote_literal` / `quote_nullable` / `quote_ident`, `elog`, `$_SHARED`. | +| 🧰 **Utilities** | `quote_literal` / `quote_nullable` / `quote_ident`, `elog`, session-global `$_SHARED` and per-function `$_SD`. | | 📦 **Session setup** | Anonymous `DO` blocks, `plphp_modules` autoloading, `plphp.on_init`, and a `plphp.start_proc` hook. | -| 🧬 **Native jsonb & hstore** | The `jsonb_plphp` and `hstore_plphp` transforms map `jsonb` and `hstore` ⇄ PHP arrays directly. | +| 🧬 **Native jsonb, hstore & bytea** | The `jsonb_plphp`, `hstore_plphp`, and `bytea_plphp` transforms map `jsonb`/`hstore` ⇄ PHP arrays and `bytea` ⇄ binary strings 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/bytea_plphp/Makefile b/bytea_plphp/Makefile new file mode 100644 index 0000000..fddc4b3 --- /dev/null +++ b/bytea_plphp/Makefile @@ -0,0 +1,37 @@ +# bytea_plphp - transform between bytea and PL/php +# +# Build after (or alongside) PL/php itself; requires the same PHP embed +# development files: +# make PG_CONFIG=/path/to/pg_config +# sudo make install +# (in a database) CREATE EXTENSION bytea_plphp CASCADE; + +MODULE_big = bytea_plphp +OBJS = bytea_plphp.o + +EXTENSION = bytea_plphp +DATA = bytea_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 = bytea_plphp + +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/bytea_plphp/bytea_plphp--1.0.sql b/bytea_plphp/bytea_plphp--1.0.sql new file mode 100644 index 0000000..3538165 --- /dev/null +++ b/bytea_plphp/bytea_plphp--1.0.sql @@ -0,0 +1,21 @@ +/* bytea_plphp--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION bytea_plphp" to load this file. \quit + +CREATE FUNCTION bytea_to_plphp(internal) +RETURNS internal +LANGUAGE C STRICT IMMUTABLE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION plphp_to_bytea(internal) +RETURNS bytea +LANGUAGE C STRICT IMMUTABLE +AS 'MODULE_PATHNAME'; + +CREATE TRANSFORM FOR bytea LANGUAGE plphp ( + FROM SQL WITH FUNCTION bytea_to_plphp(internal), + TO SQL WITH FUNCTION plphp_to_bytea(internal)); + +COMMENT ON TRANSFORM FOR bytea LANGUAGE plphp IS + 'transform between bytea and PL/php'; diff --git a/bytea_plphp/bytea_plphp.c b/bytea_plphp/bytea_plphp.c new file mode 100644 index 0000000..03835ed --- /dev/null +++ b/bytea_plphp/bytea_plphp.c @@ -0,0 +1,98 @@ +/********************************************************************** + * bytea_plphp.c + * + * Transform between bytea and PL/php: with CREATE FUNCTION ... TRANSFORM + * FOR TYPE bytea, a bytea argument arrives in PHP as a raw binary string + * (the bytes themselves, not the "\x..." text form), and a PHP string + * returned from the function is stored verbatim as bytea. This mirrors + * PL/Python's bytea <-> bytes mapping. + * + * PHP strings are length-counted and binary-safe, so embedded NUL bytes + * survive the round trip in both directions (unlike the default text path, + * which routes bytea through byteaout/byteain). + * + * This software is copyright (c) Command Prompt Inc. Same license as + * PL/php itself; see plphp.c. + ********************************************************************* + */ + +#include "postgres.h" + +#include "fmgr.h" +/* The varlena macros (VARDATA/SET_VARSIZE/...) moved to varatt.h in PG 16; + * before that they live in postgres.h, which is always included. */ +#if PG_VERSION_NUM >= 160000 +#include "varatt.h" +#endif +#include "utils/builtins.h" + +/* + * These are defined again in php.h, so undef them to avoid some + * cpp warnings. + */ +#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; + +/* + * bytea -> PHP (the FROM SQL transform function) + * + * The bytes become a binary-safe PHP string. PG_GETARG_BYTEA_PP detoasts + * a compressed or out-of-line value first. + */ +PG_FUNCTION_INFO_V1(bytea_to_plphp); + +Datum +bytea_to_plphp(PG_FUNCTION_ARGS) +{ + bytea *in = PG_GETARG_BYTEA_PP(0); + zval *z = (zval *) emalloc(sizeof(zval)); + + ZVAL_STRINGL(z, VARDATA_ANY(in), VARSIZE_ANY_EXHDR(in)); + + PG_RETURN_POINTER(z); +} + +/* + * PHP -> bytea (the TO SQL transform function) + * + * A PHP string is stored byte-for-byte. Any other type is rejected: bytea + * has no meaningful mapping from an array/object, and silently coercing an + * int/float/bool to its textual bytes would surprise more than it helps. + */ +PG_FUNCTION_INFO_V1(plphp_to_bytea); + +Datum +plphp_to_bytea(PG_FUNCTION_ARGS) +{ + zval *in = (zval *) PG_GETARG_POINTER(0); + size_t len; + bytea *out; + + ZVAL_DEREF(in); + + if (Z_TYPE_P(in) != IS_STRING) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("cannot transform this PHP value to bytea"), + errdetail("Only a PHP string maps to bytea; got PHP type %d.", + (int) Z_TYPE_P(in)))); + + len = Z_STRLEN_P(in); + out = (bytea *) palloc(len + VARHDRSZ); + SET_VARSIZE(out, len + VARHDRSZ); + memcpy(VARDATA(out), Z_STRVAL_P(in), len); + + PG_RETURN_BYTEA_P(out); +} + +/* + * vim:ts=4:sw=4:cino=(0 + */ diff --git a/bytea_plphp/bytea_plphp.control b/bytea_plphp/bytea_plphp.control new file mode 100644 index 0000000..4c1222b --- /dev/null +++ b/bytea_plphp/bytea_plphp.control @@ -0,0 +1,6 @@ +# bytea_plphp extension +comment = 'transform between bytea and PL/php' +default_version = '1.0' +module_pathname = '$libdir/bytea_plphp' +relocatable = true +requires = 'plphp' diff --git a/bytea_plphp/expected/bytea_plphp.out b/bytea_plphp/expected/bytea_plphp.out new file mode 100644 index 0000000..6e6ea2f --- /dev/null +++ b/bytea_plphp/expected/bytea_plphp.out @@ -0,0 +1,106 @@ +CREATE EXTENSION bytea_plphp CASCADE; +NOTICE: installing required extension "plphp" +-- A bytea argument arrives as a raw PHP string; a returned string becomes +-- bytea verbatim. Round-trip an ordinary value. +CREATE FUNCTION roundtrip(val bytea) RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return $args[0]; +$$; +SELECT roundtrip('\x00ff01'::bytea); + roundtrip +----------- + \x00ff01 +(1 row) + +SELECT roundtrip('hello'::bytea); + roundtrip +-------------- + \x68656c6c6f +(1 row) + +-- empty bytea +SELECT roundtrip(''::bytea); + roundtrip +----------- + \x +(1 row) + +-- Binary-safe: embedded NUL bytes survive both directions (the default text +-- path would truncate them). strlen sees the true byte count. +CREATE FUNCTION bytelen(val bytea) RETURNS int +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return strlen($args[0]); +$$; +SELECT bytelen('\x00010200'::bytea); + bytelen +--------- + 4 +(1 row) + +SELECT length(roundtrip('\x00010200'::bytea)); + length +-------- + 4 +(1 row) + +-- What PHP sees really is bytes, not the "\x..." text form: index into them. +CREATE FUNCTION byteat(val bytea, idx int) RETURNS int +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return ord($args[0][$args[1]]); +$$; +SELECT byteat('\xdeadbeef'::bytea, 0); + byteat +-------- + 222 +(1 row) + +SELECT byteat('\xdeadbeef'::bytea, 3); + byteat +-------- + 239 +(1 row) + +-- Build a bytea from raw bytes in PHP. +CREATE FUNCTION make_bytes() RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return chr(0) . chr(255) . chr(16); +$$; +SELECT make_bytes(); + make_bytes +------------ + \x00ff10 +(1 row) + +-- NULL passes through as SQL NULL (the transform is STRICT / null-safe). +SELECT roundtrip(NULL::bytea) IS NULL AS is_null; + is_null +--------- + t +(1 row) + +-- Nested context: a bytea column inside a composite result also gets the +-- transform (the #30 nested-transform machinery), so PHP works with raw bytes +-- when building the row. +CREATE TYPE blob_row AS (tag text, data bytea); +CREATE FUNCTION make_row(t text, d bytea) RETURNS blob_row +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + // $args[1] is raw bytes; prepend a byte and return the row + return array('tag' => $args[0], 'data' => chr(1) . $args[1]); +$$; +SELECT (make_row('x', '\xaabb'::bytea)).*; + tag | data +-----+---------- + x | \x01aabb +(1 row) + +-- Returning a non-string PHP value for bytea is an error. +CREATE FUNCTION bad_return() RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return array(1, 2, 3); +$$; +SELECT bad_return(); +ERROR: cannot transform this PHP value to bytea +DETAIL: Only a PHP string maps to bytea; got PHP type 7. +CONTEXT: PL/php function "bad_return" +DROP TYPE blob_row CASCADE; +NOTICE: drop cascades to function make_row(text,bytea) diff --git a/bytea_plphp/sql/bytea_plphp.sql b/bytea_plphp/sql/bytea_plphp.sql new file mode 100644 index 0000000..e468eaf --- /dev/null +++ b/bytea_plphp/sql/bytea_plphp.sql @@ -0,0 +1,66 @@ +CREATE EXTENSION bytea_plphp CASCADE; + +-- A bytea argument arrives as a raw PHP string; a returned string becomes +-- bytea verbatim. Round-trip an ordinary value. +CREATE FUNCTION roundtrip(val bytea) RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return $args[0]; +$$; + +SELECT roundtrip('\x00ff01'::bytea); +SELECT roundtrip('hello'::bytea); +-- empty bytea +SELECT roundtrip(''::bytea); + +-- Binary-safe: embedded NUL bytes survive both directions (the default text +-- path would truncate them). strlen sees the true byte count. +CREATE FUNCTION bytelen(val bytea) RETURNS int +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return strlen($args[0]); +$$; + +SELECT bytelen('\x00010200'::bytea); +SELECT length(roundtrip('\x00010200'::bytea)); + +-- What PHP sees really is bytes, not the "\x..." text form: index into them. +CREATE FUNCTION byteat(val bytea, idx int) RETURNS int +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return ord($args[0][$args[1]]); +$$; + +SELECT byteat('\xdeadbeef'::bytea, 0); +SELECT byteat('\xdeadbeef'::bytea, 3); + +-- Build a bytea from raw bytes in PHP. +CREATE FUNCTION make_bytes() RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return chr(0) . chr(255) . chr(16); +$$; + +SELECT make_bytes(); + +-- NULL passes through as SQL NULL (the transform is STRICT / null-safe). +SELECT roundtrip(NULL::bytea) IS NULL AS is_null; + +-- Nested context: a bytea column inside a composite result also gets the +-- transform (the #30 nested-transform machinery), so PHP works with raw bytes +-- when building the row. +CREATE TYPE blob_row AS (tag text, data bytea); + +CREATE FUNCTION make_row(t text, d bytea) RETURNS blob_row +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + // $args[1] is raw bytes; prepend a byte and return the row + return array('tag' => $args[0], 'data' => chr(1) . $args[1]); +$$; + +SELECT (make_row('x', '\xaabb'::bytea)).*; + +-- Returning a non-string PHP value for bytea is an error. +CREATE FUNCTION bad_return() RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + return array(1, 2, 3); +$$; + +SELECT bad_return(); + +DROP TYPE blob_row CASCADE; diff --git a/doc/plperl-comparison.md b/doc/plperl-comparison.md index d344d26..ca4ec8b 100644 --- a/doc/plperl-comparison.md +++ b/doc/plperl-comparison.md @@ -16,6 +16,7 @@ intentionally out of scope, with the rationale given. | `RETURNS TABLE` / OUT / INOUT arguments | ✅ | ✅ | PL/php also supports named parameters, which PL/Perl does not | | Trigger functions (`$_TD`) | ✅ | ✅ | | | Session-shared data | `%_SHARED` | `$_SHARED` | | +| **Private per-function data** | ❌ | `$_SD` **added** | like PL/Python's `SD`; PL/Perl has no per-function store | | **Anonymous code blocks (`DO`)** | ✅ | ✅ **added** | `DO $$ ... $$ LANGUAGE plphp` | | **Procedures with transaction control** | ✅ | ✅ **added** | `CALL` a `PROCEDURE`; `spi_commit`/`spi_rollback` in a non-atomic context | | Trusted (sandboxed) variant | `plperl` (Safe.pm) | ❌ by design | PHP's `safe_mode` was removed in 5.4; PL/php is untrusted/superuser-only (see [Security](plphp.md#security)) | diff --git a/doc/plphp.md b/doc/plphp.md index 2c897a9..d401e91 100644 --- a/doc/plphp.md +++ b/doc/plphp.md @@ -30,6 +30,7 @@ SAPI, non-thread-safe). - [Quoting helpers](#quoting-helpers) - [Messaging: elog and pg_raise](#messaging-elog-and-pg_raise) - [Shared data: `$_SHARED`](#shared-data-_shared) +- [Private data: `$_SD`](#private-data-_sd) - [Session initialization: modules and start_proc](#session-initialization-modules-and-start_proc) - [Errors and exceptions](#errors-and-exceptions) - [Security](#security) @@ -165,11 +166,35 @@ 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). +### Native bytea via the `bytea_plphp` transform + +By default a `bytea` value crosses the boundary in its text form (the +`\x...` hex output, decoded again by `byteain` on the way back), which is not +binary-safe: an embedded NUL truncates the value. The companion `bytea_plphp` +extension maps `bytea` to a raw PHP string instead, mirroring PL/Python's +`bytea` <-> `bytes`. With `TRANSFORM FOR TYPE bytea` the argument is the bytes +themselves and a returned PHP string is stored verbatim, NULs and all. + +```sql +CREATE EXTENSION bytea_plphp CASCADE; -- pulls in plphp + +CREATE FUNCTION xor_byte(data bytea, mask int) RETURNS bytea +LANGUAGE plphp TRANSFORM FOR TYPE bytea AS $$ + $out = ''; + for ($i = 0; $i < strlen($args[0]); $i++) + $out .= chr(ord($args[0][$i]) ^ $args[1]); + return $out; +$$; +``` + +Only a PHP string maps to `bytea`; returning any other type (array, int, ...) +is rejected with a clear error. A PHP `null` yields SQL `NULL`. + ### Where transforms apply -A declared transform (`jsonb` or `hstore`) is not limited to top-level -arguments and results. For a function that declares it, the transform also -converts values of that type **inside**: +A declared transform (`jsonb`, `hstore`, or `bytea`) is not limited to +top-level arguments and results. For a function that declares it, the transform +also converts values of that type **inside**: - a composite argument's fields, and a composite/record result's fields; - `RETURNS SETOF` / `RETURNS TABLE` rows emitted with `return_next`; @@ -549,6 +574,30 @@ CREATE FUNCTION get_shared(key text) RETURNS text LANGUAGE plphp AS $$ $$; ``` +## Private data: `$_SD` + +`$_SD` is an associative array private to each function that persists across +calls to that function within the same session. It is the per-function +counterpart to the session-global `$_SHARED` (the pair mirrors PL/Python's `SD` +and `GD`). Two different functions never share an `$_SD`; recursive calls of one +function do. The array starts empty and is reset when the function is redefined +with `CREATE OR REPLACE` or the session ends. + +The usual use is caching a prepared plan so it is built once per session without +crowding the shared namespace: + +```sql +CREATE FUNCTION log_event(msg text) RETURNS void LANGUAGE plphp AS $$ + if (!isset($_SD['plan'])) + $_SD['plan'] = spi_prepare('INSERT INTO log(msg) VALUES ($1)', + array('text')); + spi_exec_prepared($_SD['plan'], array($args[0])); +$$; +``` + +A plain PHP `static` local works for the same purpose; `$_SD` is a ready-made, +always-present dictionary and the name PL/Python users will reach for. + ## Session initialization: modules and start_proc Two mechanisms let you run PHP setup code the first time PL/php is used in a diff --git a/doc/pltcl-comparison.md b/doc/pltcl-comparison.md index 9eaf199..302f9d6 100644 --- a/doc/pltcl-comparison.md +++ b/doc/pltcl-comparison.md @@ -13,6 +13,7 @@ its rationale. | DML trigger functions | ✅ | ✅ | `$_TD` in PL/php; `$TG_*`/`$NEW`/`$OLD` in PL/Tcl | | **Event trigger functions** | ✅ | ✅ **added** | `RETURNS event_trigger`; `$_TD['event']`, `$_TD['tag']` | | Session-shared data | `GD` array | `$_SHARED` | | +| **Private per-function data** | ❌ | `$_SD` **added** | like PL/Python's `SD` (`$_SHARED` is the `GD` counterpart) | | Transaction control in procedures | `commit`/`rollback` | `spi_commit`/`spi_rollback` | | | **Explicit subtransactions** | `subtransaction {…}` | `subtransaction(callable, …)` **added** | | | **Module autoloading** | `pltcl_modules` + `unknown` | `plphp_modules` **added** | PL/php eager-loads all module rows at interpreter init | diff --git a/expected/sd.out b/expected/sd.out new file mode 100644 index 0000000..33a1fe5 --- /dev/null +++ b/expected/sd.out @@ -0,0 +1,125 @@ +-- +-- $_SD test: per-function static dictionary +-- +-- $_SD persists across calls to the same function within a session, is private +-- to each function (unlike the session-global $_SHARED), and is reset when the +-- function is redefined. +-- +-- A counter that lives in $_SD across calls. +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 0; + return ++$_SD['n']; +$$ LANGUAGE plphp; +SELECT sd_counter(); + sd_counter +------------ + 1 +(1 row) + +SELECT sd_counter(); + sd_counter +------------ + 2 +(1 row) + +SELECT sd_counter(); + sd_counter +------------ + 3 +(1 row) + +-- A second function has its own, independent $_SD. +CREATE OR REPLACE FUNCTION sd_counter2() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 100; + return ++$_SD['n']; +$$ LANGUAGE plphp; +SELECT sd_counter2(); + sd_counter2 +------------- + 101 +(1 row) + +SELECT sd_counter2(); + sd_counter2 +------------- + 102 +(1 row) + +-- The first counter is unaffected by the second. +SELECT sd_counter(); + sd_counter +------------ + 4 +(1 row) + +-- $_SD is private; $_SHARED is shared. Write both from one function... +CREATE OR REPLACE FUNCTION sd_writer(text) RETURNS text AS $$ + global $_SHARED; + $_SD['secret'] = $args[0]; + $_SHARED['public'] = $args[0]; + return 'ok'; +$$ LANGUAGE plphp; +-- ...and read them from another. It sees $_SHARED but not the writer's $_SD. +CREATE OR REPLACE FUNCTION sd_reader() RETURNS text AS $$ + global $_SHARED; + $sd = isset($_SD['secret']) ? $_SD['secret'] : 'none'; + $sh = isset($_SHARED['public']) ? $_SHARED['public'] : 'none'; + return "sd=$sd shared=$sh"; +$$ LANGUAGE plphp; +SELECT sd_writer('xyz'); + sd_writer +----------- + ok +(1 row) + +SELECT sd_reader(); + sd_reader +-------------------- + sd=none shared=xyz +(1 row) + +-- Redefining a function resets its $_SD. +SELECT sd_counter(); + sd_counter +------------ + 5 +(1 row) + +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 0; + return ++$_SD['n']; +$$ LANGUAGE plphp; +SELECT sd_counter(); + sd_counter +------------ + 1 +(1 row) + +-- $_SD can hold structured data, e.g. a cached array. +CREATE OR REPLACE FUNCTION sd_accumulate(int) RETURNS int[] AS $$ + if (!isset($_SD['seen'])) + $_SD['seen'] = array(); + $_SD['seen'][] = $args[0]; + return $_SD['seen']; +$$ LANGUAGE plphp; +SELECT sd_accumulate(10); + sd_accumulate +--------------- + {10} +(1 row) + +SELECT sd_accumulate(20); + sd_accumulate +--------------- + {10,20} +(1 row) + +SELECT sd_accumulate(30); + sd_accumulate +--------------- + {10,20,30} +(1 row) + diff --git a/plphp.c b/plphp.c index a41123b..882bbb1 100644 --- a/plphp.c +++ b/plphp.c @@ -132,6 +132,14 @@ PG_MODULE_MAGIC; #define PLPHP_ARG_VALUE(fcinfo, n) ((fcinfo)->arg[n]) #endif +/* + * Prologue prepended to every compiled function body. It declares $_SD, a + * per-function static dictionary that persists across calls within a session + * (see the comment where the wrapper source is built). The trailing space + * keeps it separate from a body that starts with a bare token. + */ +#define PLPHP_SD_PROLOGUE "static $_SD = array(); " + /* Check the argument type to expect to accept an initial value */ #define IS_ARGMODE_OUT(mode) ((mode) == PROARGMODE_OUT || \ (mode) == PROARGMODE_TABLE) @@ -2170,26 +2178,37 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) complete_proc_source = (char *) palloc(strlen(proc_source) + strlen(internal_proname) + - (aliases ? strlen(aliases) : 0) + + (aliases ? strlen(aliases) : 0) + (out_aliases ? strlen(out_aliases) : 0) + - strlen("function ($args, $argc){ } ") + 32 + + strlen("function ($args, $argc){ } ") + + strlen(PLPHP_SD_PROLOGUE) + 32 + (out_return_str ? strlen(out_return_str) : 0)); - /* XXX Is this usage of sprintf safe? */ + /* + * XXX Is this usage of sprintf safe? + * + * Every wrapper opens with PLPHP_SD_PROLOGUE, which declares $_SD as a + * function-local static array. Because the wrapped function is compiled + * once per session, the static persists across calls, giving each + * function its own private dictionary (the session-global counterpart is + * $_SHARED). Recompiling the function (CREATE OR REPLACE) installs a + * fresh op_array, so $_SD starts empty again. + */ if (is_trigger) /* $_TD is passed by reference so the function can modify NEW */ - sprintf(complete_proc_source, "function %s(&$_TD){%s}", - internal_proname, proc_source); + sprintf(complete_proc_source, "function %s(&$_TD){%s%s}", + internal_proname, PLPHP_SD_PROLOGUE, proc_source); else if (is_event_trigger) - sprintf(complete_proc_source, "function %s($_TD){%s}", - internal_proname, proc_source); + sprintf(complete_proc_source, "function %s($_TD){%s%s}", + internal_proname, PLPHP_SD_PROLOGUE, proc_source); else - sprintf(complete_proc_source, - "function %s($args, $argc){%s %s;%s; %s}", - internal_proname, + sprintf(complete_proc_source, + "function %s($args, $argc){%s%s %s;%s; %s}", + internal_proname, + PLPHP_SD_PROLOGUE, aliases ? aliases : "", out_aliases ? out_aliases : "", - proc_source, + proc_source, out_return_str? out_return_str : ""); elog(LOG, "complete_proc_source = %s", diff --git a/sql/sd.sql b/sql/sd.sql new file mode 100644 index 0000000..2f43b85 --- /dev/null +++ b/sql/sd.sql @@ -0,0 +1,70 @@ +-- +-- $_SD test: per-function static dictionary +-- +-- $_SD persists across calls to the same function within a session, is private +-- to each function (unlike the session-global $_SHARED), and is reset when the +-- function is redefined. +-- + +-- A counter that lives in $_SD across calls. +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 0; + return ++$_SD['n']; +$$ LANGUAGE plphp; + +SELECT sd_counter(); +SELECT sd_counter(); +SELECT sd_counter(); + +-- A second function has its own, independent $_SD. +CREATE OR REPLACE FUNCTION sd_counter2() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 100; + return ++$_SD['n']; +$$ LANGUAGE plphp; + +SELECT sd_counter2(); +SELECT sd_counter2(); +-- The first counter is unaffected by the second. +SELECT sd_counter(); + +-- $_SD is private; $_SHARED is shared. Write both from one function... +CREATE OR REPLACE FUNCTION sd_writer(text) RETURNS text AS $$ + global $_SHARED; + $_SD['secret'] = $args[0]; + $_SHARED['public'] = $args[0]; + return 'ok'; +$$ LANGUAGE plphp; + +-- ...and read them from another. It sees $_SHARED but not the writer's $_SD. +CREATE OR REPLACE FUNCTION sd_reader() RETURNS text AS $$ + global $_SHARED; + $sd = isset($_SD['secret']) ? $_SD['secret'] : 'none'; + $sh = isset($_SHARED['public']) ? $_SHARED['public'] : 'none'; + return "sd=$sd shared=$sh"; +$$ LANGUAGE plphp; + +SELECT sd_writer('xyz'); +SELECT sd_reader(); + +-- Redefining a function resets its $_SD. +SELECT sd_counter(); +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int AS $$ + if (!isset($_SD['n'])) + $_SD['n'] = 0; + return ++$_SD['n']; +$$ LANGUAGE plphp; +SELECT sd_counter(); + +-- $_SD can hold structured data, e.g. a cached array. +CREATE OR REPLACE FUNCTION sd_accumulate(int) RETURNS int[] AS $$ + if (!isset($_SD['seen'])) + $_SD['seen'] = array(); + $_SD['seen'][] = $args[0]; + return $_SD['seen']; +$$ LANGUAGE plphp; + +SELECT sd_accumulate(10); +SELECT sd_accumulate(20); +SELECT sd_accumulate(30);