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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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()
Expand All @@ -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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 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)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions bytea_plphp/Makefile
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions bytea_plphp/bytea_plphp--1.0.sql
Original file line number Diff line number Diff line change
@@ -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';
98 changes: 98 additions & 0 deletions bytea_plphp/bytea_plphp.c
Original file line number Diff line number Diff line change
@@ -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
*/
6 changes: 6 additions & 0 deletions bytea_plphp/bytea_plphp.control
Original file line number Diff line number Diff line change
@@ -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'
106 changes: 106 additions & 0 deletions bytea_plphp/expected/bytea_plphp.out
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading