From d7170623580b401a63ceda09c006beb22c99e501 Mon Sep 17 00:00:00 2001 From: "Joshua (D) Drake" <136637981+ChronicallyJD@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:56:31 -0600 Subject: [PATCH] Convert composite values structurally, in both directions Completes the structural-conversion work begun with array columns: composite-typed columns in rows, the fields of composite arguments, and composites inside arrays now arrive in PHP as associative arrays, recursively (a composite containing arrays containing composites nests all the way down). The reverse direction works symmetrically: a PHP array destined for a composite-typed slot -- a return value, a return_next row's column, an OUT argument, or a trigger MODIFY field -- is rendered as a record literal, so nested structures can be built from plain PHP arrays and modified in place in triggers. The machinery is a parser for the composite output format (quote and backslash doubling, empty-field NULLs, dropped-column skipping), mirrored by a record-literal builder that always double-quotes non-null fields; the array parser and builder gained an element-type parameter so composite elements convert too. Column/field semantics match row columns (scalars stay strings); the per-argument element type replaces the old arg_is_array flag. Also accept anycompatible and anycompatiblearray as polymorphic argument/return types on PostgreSQL 13+ (an arrays_1.out variant covers 11/12, where the types do not exist). New regression cases: composite columns with nested composite and array fields (with embedded quotes, commas, and parentheses), composite arguments, composites inside array arguments, building a nested composite from associative arrays, trigger MODIFY through a nested composite field, NULL fields in both directions, and the anycompatible polymorphics. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++ doc/plphp.md | 14 +- expected/arrays.out | 111 +++++++++++++ expected/arrays_1.out | 307 ++++++++++++++++++++++++++++++++++ plphp.c | 27 ++- plphp_io.c | 377 ++++++++++++++++++++++++++++++++++++++---- plphp_io.h | 5 +- sql/arrays.sql | 74 +++++++++ 8 files changed, 884 insertions(+), 47 deletions(-) create mode 100644 expected/arrays_1.out diff --git a/CHANGELOG.md b/CHANGELOG.md index 1563f30..6c629d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,22 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). environment pointing into a dead stack frame; the next uncaught error then jumped into garbage. Nested regression cases added. +## [Unreleased] + +### Added + +- **Composite values convert structurally.** Composite-typed columns in rows, + fields of composite arguments, and composites inside arrays now arrive as + associative PHP arrays (recursively, arrays included), and PHP arrays + convert back to composite values on return, in `return_next` rows, and + through trigger `MODIFY` — completing the structural-conversion work begun + with array columns in 2.3.0. +- `anycompatible` and `anycompatiblearray` are accepted as polymorphic + argument/return types on PostgreSQL 13 and newer. +- **ASAN in CI.** A workflow job builds with AddressSanitizer and runs the + suite against a `libasan`-preloaded server, catching memory-safety bugs + mechanically (new `ASAN_FLAGS` hook in the Makefiles). + ## [2.3.0] — 2026-07-06 ### Added diff --git a/doc/plphp.md b/doc/plphp.md index bd0a537..17b8c7f 100644 --- a/doc/plphp.md +++ b/doc/plphp.md @@ -88,10 +88,12 @@ function. In practice: | arrays (e.g. `int[]`) | PHP array | PHP array | | composite / row / record | associative array | associative array | -Array-typed *columns* inside rows — in `$_TD['new']`/`['old']`, rows from -`spi_fetch_row`/`spi_fetchrow`, and composite arguments' fields — also arrive -as PHP arrays, and can be assigned back as arrays (e.g. before a trigger -`MODIFY`). +Array- and composite-typed *columns* inside rows — in `$_TD['new']`/`['old']`, +rows from `spi_fetch_row`/`spi_fetchrow`, and composite arguments' fields — +also convert structurally, all the way down: arrays become PHP arrays, +composites become associative arrays, in both directions (so a trigger can +`MODIFY` a nested composite field, and a function can return one built from +plain PHP arrays). | NULL | unset / null | `return;` or `null` | Arrays map naturally, including multidimensional arrays: @@ -150,6 +152,10 @@ SELECT * FROM make_tuple('answer', 42); -- answer | 42 Functions declared `RETURNS record` must be called with a column definition list, e.g. `SELECT * FROM f() AS (a int, b text)`. +Nesting converts recursively in both directions: a composite containing +arrays or other composites arrives as nested PHP arrays, and can be returned +the same way. + ## Arguments PL/php supports the full range of argument modes. diff --git a/expected/arrays.out b/expected/arrays.out index 7d00b9e..25f4aed 100644 --- a/expected/arrays.out +++ b/expected/arrays.out @@ -195,3 +195,114 @@ select arr_in_comp(row('x', array[5, 6, 7])::with_arr); drop function arr_in_row(), arr_in_cursor(), arr_in_comp(with_arr), arr_trig(); drop type with_arr; drop table arrt; +-- composite conversion: composite columns/fields/elements are associative +-- arrays in both directions +create type arr_addr as (street text, city text); +create type arr_person as (name text, home arr_addr, tags text[]); +create table arr_folks (id int, who arr_person); +insert into arr_folks values + (1, row('J''s "Place"', row('1 Main, Apt (2)', 'Olympia')::arr_addr, + array['a', 'b,c'])::arr_person); +-- composite column in a row: nested composite and array fields convert +create function arr_rec_peek() returns text language plphp as $$ + $r = spi_exec("select * from arr_folks"); + $row = spi_fetch_row($r); + $w = $row['who']; + return gettype($w) . " | " . $w['name'] . " | " . $w['home']['city'] + . " | " . $w['tags'][1] . " | " . gettype($w['tags']); +$$; +select arr_rec_peek(); + arr_rec_peek +--------------------------------------------- + array | J's "Place" | Olympia | b,c | array +(1 row) + +-- composite argument (with nesting) +create function arr_rec_arg(p arr_person) returns text language plphp as $$ + return $args[0]['home']['street'] . " / " . count($args[0]['tags']); +$$; +select arr_rec_arg(row('X', row('5 Oak', 'Tacoma')::arr_addr, array['q'])::arr_person); + arr_rec_arg +------------- + 5 Oak / 1 +(1 row) + +-- composites inside an array argument +create function arr_rec_elems(addrs arr_addr[]) returns text language plphp as $$ + return $args[0][1]['city']; +$$; +select arr_rec_elems(array[row('a', 'CityA')::arr_addr, row('b', 'City,B()')::arr_addr]); + arr_rec_elems +--------------- + City,B() +(1 row) + +-- the reverse: build a nested composite from associative arrays +create function arr_rec_make() returns arr_person language plphp as $$ + return array( + 'name' => 'He said "hi"', + 'home' => array('street' => '9 Elm, S(2)', 'city' => 'B\\C'), + 'tags' => array('x', 'y z') + ); +$$; +select * from arr_rec_make(); + name | home | tags +--------------+------------------------+----------- + He said "hi" | ("9 Elm, S(2)","B\\C") | {x,"y z"} +(1 row) + +select (arr_rec_make()).home.city, (arr_rec_make()).tags[2]; + city | tags +------+------ + B\C | y z +(1 row) + +-- trigger MODIFY through a composite column +create function arr_rec_trig() returns trigger language plphp as $$ + $_TD['new']['who']['home']['city'] = 'Changed'; + return 'MODIFY'; +$$; +create trigger arr_folks_trg before insert on arr_folks + for each row execute procedure arr_rec_trig(); +insert into arr_folks values + (2, row('K', row('7 Pine', 'Old')::arr_addr, array['z'])::arr_person); +select (who).home.city from arr_folks where id = 2; + city +--------- + Changed +(1 row) + +drop trigger arr_folks_trg on arr_folks; +-- NULL fields inside composites survive both directions +create function arr_rec_nulls(p arr_addr) returns arr_addr language plphp as $$ + pg_raise('notice', 'street is ' . ($args[0]['street'] === null ? 'null' : 'set')); + return array('street' => null, 'city' => $args[0]['city']); +$$; +select * from arr_rec_nulls(row(NULL, 'Salem')::arr_addr); +NOTICE: plphp: street is null + street | city +--------+------- + | Salem +(1 row) + +drop function arr_rec_peek(), arr_rec_arg(arr_person), arr_rec_elems(arr_addr[]), + arr_rec_make(), arr_rec_trig(), arr_rec_nulls(arr_addr); +drop table arr_folks; +drop type arr_person, arr_addr; +-- anycompatible polymorphics (PostgreSQL 13+) +create function arr_first_compat(anycompatiblearray) returns anycompatible +language plphp as $$ + return $args[0][0]; +$$; +select arr_first_compat(array[7, 8, 9]); + arr_first_compat +------------------ + 7 +(1 row) + +select arr_first_compat(array['x', 'y']); + arr_first_compat +------------------ + x +(1 row) + diff --git a/expected/arrays_1.out b/expected/arrays_1.out new file mode 100644 index 0000000..f6f0488 --- /dev/null +++ b/expected/arrays_1.out @@ -0,0 +1,307 @@ +-- array conversion hardening: NULL elements, escaping, unquoted text, +-- numeric element types, empty arrays, bytea round-trips +-- returning an array containing a PHP null produces a SQL NULL element +create function arr_with_null() returns int[] language plphp as $$ + return array(1, null, 3); +$$; +select arr_with_null(); + arr_with_null +--------------- + {1,NULL,3} +(1 row) + +-- text elements needing quoting/escaping survive the round trip out... +create function arr_specials_out() returns text[] language plphp as $$ + return array('plain', 'has"quote', 'back\\slash', 'com,ma', '{brace}', 'sp ace', ''); +$$; +select arr_specials_out(); + arr_specials_out +------------------------------------------------------------------- + {plain,"has\"quote","back\\slash","com,ma","{brace}","sp ace",""} +(1 row) + +-- ... and back in again (returns each element base64'd so the diff is unambiguous) +create function arr_specials_in(text[]) returns text language plphp as $$ + $out = array(); + foreach ($args[0] as $el) + $out[] = $el === null ? "NULL" : base64_encode($el); + return implode(" ", $out); +$$; +select arr_specials_in(array['plain', 'has"quote', 'back\slash', 'com,ma', '{brace}', 'sp ace', '', null]); + arr_specials_in +----------------------------------------------------------------------------- + cGxhaW4= aGFzInF1b3Rl YmFja1xzbGFzaA== Y29tLG1h e2JyYWNlfQ== c3AgYWNl NULL +(1 row) + +-- unquoted text elements (PG quotes only when needed) arrive as strings +create function arr_unquoted(text[]) returns text language plphp as $$ + return implode("|", $args[0]) . " (" . gettype($args[0][0]) . ")"; +$$; +select arr_unquoted(array['foo', 'bar', 'baz']); + arr_unquoted +---------------------- + foo|bar|baz (string) +(1 row) + +-- numeric arrays keep PHP numeric types +create function arr_types(int[], float8[]) returns text language plphp as $$ + return gettype($args[0][0]) . " " . gettype($args[1][0]); +$$; +select arr_types(array[1, 2], array[1.5, 2.5]); + arr_types +---------------- + integer double +(1 row) + +-- int array containing a NULL on input +create function arr_null_in(int[]) returns int language plphp as $$ + $sum = 0; $nulls = 0; + foreach ($args[0] as $el) + { + if ($el === null) $nulls++; + else $sum += $el; + } + return $sum * 10 + $nulls; +$$; +select arr_null_in(array[1, null, 3]); + arr_null_in +------------- + 41 +(1 row) + +-- empty arrays, both directions +create function arr_empty_out() returns int[] language plphp as $$ + return array(); +$$; +select arr_empty_out(); + arr_empty_out +--------------- + {} +(1 row) + +create function arr_empty_in(int[]) returns int language plphp as $$ + return count($args[0]); +$$; +select arr_empty_in('{}'); + arr_empty_in +-------------- + 0 +(1 row) + +-- multi-dimensional array of strings needing quoting +create function arr_ndim_text() returns text[] language plphp as $$ + return array(array('a"b', 'c,d'), array('{e}', 'f\\g')); +$$; +select arr_ndim_text(); + arr_ndim_text +--------------------------------- + {{"a\"b","c,d"},{"{e}","f\\g"}} +(1 row) + +-- multi-dimensional input indexes correctly +create function arr_ndim_in(text[]) returns text language plphp as $$ + return $args[0][1][0]; +$$; +select arr_ndim_in(array[array['a', 'b'], array['see', 'd']]); + arr_ndim_in +------------- + see +(1 row) + +-- bytea round-trip: PL/php sees the \x hex output form +create function bytea_roundtrip(bytea) returns bytea language plphp as $$ + return $args[0]; +$$; +select bytea_roundtrip('\x0001ff68656c6c6f'); + bytea_roundtrip +-------------------- + \x0001ff68656c6c6f +(1 row) + +create function bytea_make(text) returns bytea language plphp as $$ + return "\\x" . bin2hex($args[0]); +$$; +select bytea_make('hi there'); + bytea_make +-------------------- + \x6869207468657265 +(1 row) + +create function bytea_read(bytea) returns text language plphp as $$ + return strtoupper(substr($args[0], 2)); +$$; +select bytea_read('\xdeadbeef'); + bytea_read +------------ + DEADBEEF +(1 row) + +-- array-typed columns inside rows arrive as PHP arrays (not "{...}" strings): +-- via SPI rows... +create table arrt (id int, tags text[], nums int[]); +insert into arrt values (1, array['red', 'b"lue'], array[10, 20, 30]); +create function arr_in_row() returns text language plphp as $$ + $r = spi_exec("select * from arrt"); + $row = spi_fetch_row($r); + return gettype($row['tags']) . " " . $row['tags'][1] . " " . array_sum($row['nums']); +$$; +select arr_in_row(); + arr_in_row +---------------- + array b"lue 60 +(1 row) + +-- ...via cursor rows... +create function arr_in_cursor() returns int language plphp as $$ + $c = spi_query("select nums from arrt"); + $row = spi_fetchrow($c); + return count($row['nums']); +$$; +select arr_in_cursor(); + arr_in_cursor +--------------- + 3 +(1 row) + +-- ...in $_TD for triggers, and writable back through MODIFY +create function arr_trig() returns trigger language plphp as $$ + pg_raise('notice', 'tags is ' . gettype($_TD['new']['tags']) + . ' with ' . count($_TD['new']['tags']) . ' elements'); + $_TD['new']['tags'][] = 'added'; + return 'MODIFY'; +$$; +create trigger arrt_trg before insert on arrt + for each row execute procedure arr_trig(); +insert into arrt values (2, array['green'], array[1]); +NOTICE: plphp: tags is array with 1 elements +select tags from arrt where id = 2; + tags +--------------- + {green,added} +(1 row) + +drop trigger arrt_trg on arrt; +-- ...and in composite-type arguments +create type with_arr as (label text, vals int[]); +create function arr_in_comp(with_arr) returns int language plphp as $$ + return array_sum($args[0]['vals']); +$$; +select arr_in_comp(row('x', array[5, 6, 7])::with_arr); + arr_in_comp +------------- + 18 +(1 row) + +drop function arr_in_row(), arr_in_cursor(), arr_in_comp(with_arr), arr_trig(); +drop type with_arr; +drop table arrt; +-- composite conversion: composite columns/fields/elements are associative +-- arrays in both directions +create type arr_addr as (street text, city text); +create type arr_person as (name text, home arr_addr, tags text[]); +create table arr_folks (id int, who arr_person); +insert into arr_folks values + (1, row('J''s "Place"', row('1 Main, Apt (2)', 'Olympia')::arr_addr, + array['a', 'b,c'])::arr_person); +-- composite column in a row: nested composite and array fields convert +create function arr_rec_peek() returns text language plphp as $$ + $r = spi_exec("select * from arr_folks"); + $row = spi_fetch_row($r); + $w = $row['who']; + return gettype($w) . " | " . $w['name'] . " | " . $w['home']['city'] + . " | " . $w['tags'][1] . " | " . gettype($w['tags']); +$$; +select arr_rec_peek(); + arr_rec_peek +--------------------------------------------- + array | J's "Place" | Olympia | b,c | array +(1 row) + +-- composite argument (with nesting) +create function arr_rec_arg(p arr_person) returns text language plphp as $$ + return $args[0]['home']['street'] . " / " . count($args[0]['tags']); +$$; +select arr_rec_arg(row('X', row('5 Oak', 'Tacoma')::arr_addr, array['q'])::arr_person); + arr_rec_arg +------------- + 5 Oak / 1 +(1 row) + +-- composites inside an array argument +create function arr_rec_elems(addrs arr_addr[]) returns text language plphp as $$ + return $args[0][1]['city']; +$$; +select arr_rec_elems(array[row('a', 'CityA')::arr_addr, row('b', 'City,B()')::arr_addr]); + arr_rec_elems +--------------- + City,B() +(1 row) + +-- the reverse: build a nested composite from associative arrays +create function arr_rec_make() returns arr_person language plphp as $$ + return array( + 'name' => 'He said "hi"', + 'home' => array('street' => '9 Elm, S(2)', 'city' => 'B\\C'), + 'tags' => array('x', 'y z') + ); +$$; +select * from arr_rec_make(); + name | home | tags +--------------+------------------------+----------- + He said "hi" | ("9 Elm, S(2)","B\\C") | {x,"y z"} +(1 row) + +select (arr_rec_make()).home.city, (arr_rec_make()).tags[2]; + city | tags +------+------ + B\C | y z +(1 row) + +-- trigger MODIFY through a composite column +create function arr_rec_trig() returns trigger language plphp as $$ + $_TD['new']['who']['home']['city'] = 'Changed'; + return 'MODIFY'; +$$; +create trigger arr_folks_trg before insert on arr_folks + for each row execute procedure arr_rec_trig(); +insert into arr_folks values + (2, row('K', row('7 Pine', 'Old')::arr_addr, array['z'])::arr_person); +select (who).home.city from arr_folks where id = 2; + city +--------- + Changed +(1 row) + +drop trigger arr_folks_trg on arr_folks; +-- NULL fields inside composites survive both directions +create function arr_rec_nulls(p arr_addr) returns arr_addr language plphp as $$ + pg_raise('notice', 'street is ' . ($args[0]['street'] === null ? 'null' : 'set')); + return array('street' => null, 'city' => $args[0]['city']); +$$; +select * from arr_rec_nulls(row(NULL, 'Salem')::arr_addr); +NOTICE: plphp: street is null + street | city +--------+------- + | Salem +(1 row) + +drop function arr_rec_peek(), arr_rec_arg(arr_person), arr_rec_elems(arr_addr[]), + arr_rec_make(), arr_rec_trig(), arr_rec_nulls(arr_addr); +drop table arr_folks; +drop type arr_person, arr_addr; +-- anycompatible polymorphics (PostgreSQL 13+) +create function arr_first_compat(anycompatiblearray) returns anycompatible +language plphp as $$ + return $args[0][0]; +$$; +ERROR: type anycompatiblearray does not exist +select arr_first_compat(array[7, 8, 9]); +ERROR: function arr_first_compat(integer[]) does not exist +LINE 1: select arr_first_compat(array[7, 8, 9]); + ^ +HINT: No function matches the given name and argument types. You might need to add explicit type casts. +select arr_first_compat(array['x', 'y']); +ERROR: function arr_first_compat(text[]) does not exist +LINE 1: select arr_first_compat(array['x', 'y']); + ^ +HINT: No function matches the given name and argument types. You might need to add explicit type casts. diff --git a/plphp.c b/plphp.c index 4a831a6..fae22ef 100644 --- a/plphp.c +++ b/plphp.c @@ -185,7 +185,7 @@ typedef struct plphp_proc_desc int n_mixed_args; FmgrInfo arg_out_func[FUNC_MAX_ARGS]; Oid arg_typioparam[FUNC_MAX_ARGS]; - bool arg_is_array[FUNC_MAX_ARGS]; + Oid arg_elemtype[FUNC_MAX_ARGS]; /* element type, or 0 */ Oid arg_transform[FUNC_MAX_ARGS]; /* FromSQL transform fn, or 0 */ Oid ret_transform; /* ToSQL transform fn, or 0 */ char arg_typtype[FUNC_MAX_ARGS]; @@ -1840,7 +1840,12 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) if ((procStruct->prorettype == VOIDOID) || (procStruct->prorettype == RECORDOID) || (procStruct->prorettype == ANYELEMENTOID) || - (procStruct->prorettype == ANYARRAYOID)) + (procStruct->prorettype == ANYARRAYOID) || +#if PG_VERSION_NUM >= 130000 + (procStruct->prorettype == ANYCOMPATIBLEOID) || + (procStruct->prorettype == ANYCOMPATIBLEARRAYOID) || +#endif + false) { /* okay */ prodesc->ret_type |= PL_PSEUDO; @@ -1870,7 +1875,11 @@ plphp_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) prodesc->ret_type |= PL_TUPLE; } - if (procStruct->prorettype == ANYARRAYOID) + if (procStruct->prorettype == ANYARRAYOID +#if PG_VERSION_NUM >= 130000 + || procStruct->prorettype == ANYCOMPATIBLEARRAYOID +#endif + ) prodesc->ret_type |= PL_ARRAY; else { @@ -1984,8 +1993,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_is_array[i] = - OidIsValid(get_element_type(argtypes[i])); + prodesc->arg_elemtype[i] = get_element_type(argtypes[i]); } if (aliases && argnames[i][0] != '\0') { @@ -2193,8 +2201,8 @@ plphp_func_build_args(plphp_proc_desc *desc, FunctionCallInfo fcinfo) perm_fmgr_info(typeStruct->typoutput, &(desc->arg_out_func[i]), desc->fn_cxt); desc->arg_typioparam[i] = typeStruct->typelem; - desc->arg_is_array[i] = (typeStruct->typlen == -1 && - OidIsValid(typeStruct->typelem)); + desc->arg_elemtype[i] = (typeStruct->typlen == -1) ? + typeStruct->typelem : InvalidOid; ReleaseSysCache(typeTup); } @@ -2259,11 +2267,12 @@ plphp_func_build_args(plphp_proc_desc *desc, FunctionCallInfo fcinfo) */ tmp = OutputFunctionCall(&(desc->arg_out_func[i]), PLPHP_ARG_VALUE(fcinfo, j)); - if (desc->arg_is_array[i]) + if (OidIsValid(desc->arg_elemtype[i])) { zval *hashref; - hashref = plphp_convert_from_pg_array(tmp); + hashref = plphp_convert_from_pg_array(tmp, + desc->arg_elemtype[i]); add_next_index_zval(retval, hashref); efree(hashref); } diff --git a/plphp_io.c b/plphp_io.c index 3d56797..52eb60e 100644 --- a/plphp_io.c +++ b/plphp_io.c @@ -18,10 +18,17 @@ #include "funcapi.h" #include "lib/stringinfo.h" #include "utils/lsyscache.h" +#include "utils/typcache.h" #include "utils/rel.h" #include "utils/syscache.h" #include "utils/memutils.h" +static zval *plphp_array_element_to_zval(const char *str, size_t len, + Oid elemtype, bool quoted); +static const char *plphp_parse_record_body(const char *p, TupleDesc tupdesc, + zval *row); +static char *plphp_build_record_literal(zval *val, TupleDesc tupdesc); + /* * plphp_add_row_value * Add one column's text value to a row hash under attname, converting @@ -31,13 +38,24 @@ static void plphp_add_row_value(zval *row, char *attname, Oid atttypid, char *value) { - if (OidIsValid(get_element_type(atttypid))) + Oid elemtype = get_element_type(atttypid); + + if (OidIsValid(elemtype)) { - zval *arr = plphp_convert_from_pg_array(value); + zval *arr = plphp_convert_from_pg_array(value, elemtype); add_assoc_zval(row, attname, arr); efree(arr); } + else if (get_typtype(atttypid) == TYPTYPE_COMPOSITE) + { + TupleDesc td = lookup_rowtype_tupdesc(atttypid, -1); + zval *rec = plphp_convert_from_pg_record(value, td); + + ReleaseTupleDesc(td); + add_assoc_zval(row, attname, rec); + efree(rec); + } else add_assoc_string(row, attname, value); } @@ -109,7 +127,8 @@ plphp_htup_from_zval(zval *val, TupleDesc tupdesc) char *key = SPI_fname(tupdesc, i + 1); zval *scalarval = plphp_array_get_elem(val, key); - values[i] = plphp_zval_get_cstring(scalarval, true, true); + values[i] = plphp_zval_get_typed_cstring(scalarval, + TupleDescAttr(tupdesc, i)->atttypid); /* * Reset the flag if even one of the keys actually exists, * even if it is NULL. @@ -131,7 +150,9 @@ plphp_htup_from_zval(zval *val, TupleDesc tupdesc) { if (i >= tupdesc->natts) break; - values[i++] = plphp_zval_get_cstring(element, true, true); + values[i] = plphp_zval_get_typed_cstring(element, + TupleDescAttr(tupdesc, i)->atttypid); + i++; } ZEND_HASH_FOREACH_END(); } @@ -219,7 +240,9 @@ plphp_srf_htup_from_zval(zval *val, AttInMetadata *attinmeta, break; } - values[i++] = plphp_zval_get_cstring(element, true, true); + values[i] = plphp_zval_get_typed_cstring(element, + TupleDescAttr(attinmeta->tupdesc, i)->atttypid); + i++; } ZEND_HASH_FOREACH_END(); } @@ -272,13 +295,17 @@ plphp_append_array_string(StringInfo str, const char *s, size_t len) * must be freed by the caller. */ char * -plphp_convert_to_pg_array(zval *array) +plphp_convert_to_pg_array_typed(zval *array, Oid elemtype) { int arr_size; zval *element; int i = 0; + bool composite_elems; StringInfoData str; + composite_elems = OidIsValid(elemtype) && + get_typtype(elemtype) == TYPTYPE_COMPOSITE; + initStringInfo(&str); arr_size = zend_hash_num_elements(Z_ARRVAL_P(array)); @@ -312,8 +339,21 @@ plphp_convert_to_pg_array(zval *array) Z_STRLEN_P(element)); break; case IS_ARRAY: - tmp = plphp_convert_to_pg_array(element); - appendStringInfoString(&str, tmp); + if (composite_elems) + { + /* one composite element: a quoted record literal */ + TupleDesc td = lookup_rowtype_tupdesc(elemtype, -1); + + tmp = plphp_build_record_literal(element, td); + ReleaseTupleDesc(td); + plphp_append_array_string(&str, tmp, strlen(tmp)); + } + else + { + tmp = plphp_convert_to_pg_array_typed(element, + elemtype); + appendStringInfoString(&str, tmp); + } pfree(tmp); break; default: @@ -333,6 +373,61 @@ plphp_convert_to_pg_array(zval *array) return str.data; } +char * +plphp_convert_to_pg_array(zval *array) +{ + return plphp_convert_to_pg_array_typed(array, InvalidOid); +} + +/* + * plphp_array_element_to_zval + * Convert one array element's text to a freshly emalloc'd zval. + * + * Composite elements become associative arrays (via the record parser). + * Other quoted elements are strings; unquoted ones are converted to PHP + * int/float when they look like numbers (preserving the semantics of the + * old zend_eval_string-based conversion) and are strings otherwise. + */ +static zval * +plphp_array_element_to_zval(const char *str, size_t len, Oid elemtype, + bool quoted) +{ + zval *z; + + if (OidIsValid(elemtype) && get_typtype(elemtype) == TYPTYPE_COMPOSITE) + { + TupleDesc td = lookup_rowtype_tupdesc(elemtype, -1); + + z = plphp_convert_from_pg_record(str, td); + ReleaseTupleDesc(td); + return z; + } + + z = (zval *) emalloc(sizeof(zval)); + + if (quoted) + ZVAL_STRINGL(z, str, len); + else if (strcmp(str, "NULL") == 0) + ZVAL_NULL(z); + else + { + char *end; + long lval; + double dval; + + errno = 0; + if (len > 0 && (lval = strtol(str, &end, 10), *end == '\0') && + errno != ERANGE) + ZVAL_LONG(z, (zend_long) lval); + else if (len > 0 && (dval = strtod(str, &end), *end == '\0')) + ZVAL_DOUBLE(z, dval); + else + ZVAL_STRINGL(z, str, len); + } + + return z; +} + /* * plphp_parse_array_body * Parse the contents of an array literal, starting just past an opening @@ -341,15 +436,12 @@ plphp_convert_to_pg_array(zval *array) * * The input always comes from an array type's output function, so the * delimiter is assumed to be a comma (true for every built-in type except - * box) and the syntax is trusted to be well-formed. - * - * Quoted elements are always strings. Unquoted elements are NULL, or are - * converted to PHP int/float when they look like numbers (preserving the - * semantics of the old zend_eval_string-based conversion), and are strings - * otherwise. + * box) and the syntax is trusted to be well-formed. elemtype, when valid, + * identifies the array's element type so composite elements can be + * converted structurally; nested sub-arrays share it (multidimensionality). */ static const char * -plphp_parse_array_body(const char *p, zval *arr) +plphp_parse_array_body(const char *p, zval *arr, Oid elemtype) { StringInfoData buf; @@ -369,11 +461,13 @@ plphp_parse_array_body(const char *p, zval *arr) zval sub; array_init(&sub); - p = plphp_parse_array_body(p + 1, &sub); + p = plphp_parse_array_body(p + 1, &sub, elemtype); add_next_index_zval(arr, &sub); } - else if (*p == '"') /* quoted element: always a string */ + else if (*p == '"') /* quoted element */ { + zval *el; + resetStringInfo(&buf); for (p++; *p != '"'; p++) { @@ -384,30 +478,27 @@ plphp_parse_array_body(const char *p, zval *arr) appendStringInfoChar(&buf, *p); } p++; /* skip the closing quote */ - add_next_index_stringl(arr, buf.data, buf.len); + el = plphp_array_element_to_zval(buf.data, buf.len, elemtype, true); + add_next_index_zval(arr, el); + efree(el); } else /* unquoted element */ { - char *end; - long lval; - double dval; + zval *el; resetStringInfo(&buf); while (*p != ',' && *p != '}' && *p != '\0') appendStringInfoChar(&buf, *p++); - errno = 0; if (strcmp(buf.data, "NULL") == 0) add_next_index_null(arr); - else if (buf.len > 0 && - (lval = strtol(buf.data, &end, 10), *end == '\0') && - errno != ERANGE) - add_next_index_long(arr, (zend_long) lval); - else if (buf.len > 0 && - (dval = strtod(buf.data, &end), *end == '\0')) - add_next_index_double(arr, dval); else - add_next_index_stringl(arr, buf.data, buf.len); + { + el = plphp_array_element_to_zval(buf.data, buf.len, elemtype, + false); + add_next_index_zval(arr, el); + efree(el); + } } /* between elements: expect a comma or the closing brace */ @@ -434,7 +525,7 @@ plphp_parse_array_body(const char *p, zval *arr) * Returns a freshly emalloc'd zval; see the ownership note in plphp_io.h. */ zval * -plphp_convert_from_pg_array(char *input) +plphp_convert_from_pg_array(char *input, Oid elemtype) { zval *retval; const char *p = input; @@ -447,7 +538,118 @@ plphp_convert_from_pg_array(char *input) retval = (zval *) emalloc(sizeof(zval)); array_init(retval); - (void) plphp_parse_array_body(p + 1, retval); + (void) plphp_parse_array_body(p + 1, retval, elemtype); + + return retval; +} + +/* + * plphp_parse_record_body + * Parse a record literal's fields, starting just past the opening + * parenthesis, adding them to row keyed by column name. Returns the + * position just past the closing parenthesis. + * + * Per the composite output format: fields are comma-separated in attribute + * order (dropped columns omitted); an empty field is NULL; quoted fields + * double any embedded quote and backslash. Array- and composite-typed + * fields convert structurally, so the result nests all the way down. + */ +static const char * +plphp_parse_record_body(const char *p, TupleDesc tupdesc, zval *row) +{ + StringInfoData buf; + int i; + + initStringInfo(&buf); + + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + char *attname; + bool isnull = false; + bool quoted = false; + + if (att->attisdropped) + continue; + attname = NameStr(att->attname); + + resetStringInfo(&buf); + + if (*p == ',' || *p == ')') + isnull = true; /* empty field */ + else if (*p == '"') + { + quoted = true; + for (p++;; p++) + { + if (*p == '\0') + elog(ERROR, "malformed record literal"); + if (*p == '\\') + { + p++; + appendStringInfoChar(&buf, *p); + continue; + } + if (*p == '"') + { + if (p[1] == '"') + { + appendStringInfoChar(&buf, '"'); + p++; + continue; + } + p++; /* closing quote */ + break; + } + appendStringInfoChar(&buf, *p); + } + } + else + { + while (*p != ',' && *p != ')' && *p != '\0') + appendStringInfoChar(&buf, *p++); + } + + if (isnull) + add_assoc_null(row, attname); + else + plphp_add_row_value(row, attname, att->atttypid, buf.data); + + (void) quoted; /* value semantics equal either way */ + + if (*p == ',') + p++; + else if (*p != ')') + elog(ERROR, "malformed record literal"); + } + + if (*p != ')') + elog(ERROR, "malformed record literal"); + p++; + + pfree(buf.data); + return p; +} + +/* + * plphp_convert_from_pg_record + * Convert a composite value's text representation to a PHP associative + * array keyed by column name (recursively, via plphp_add_row_value). + * + * Returns a freshly emalloc'd zval; see the ownership note in plphp_io.h. + */ +zval * +plphp_convert_from_pg_record(const char *input, TupleDesc tupdesc) +{ + zval *retval; + const char *p = input; + + if (*p != '(') + elog(ERROR, "expected a record literal, got \"%s\"", input); + + retval = (zval *) emalloc(sizeof(zval)); + array_init(retval); + (void) plphp_parse_record_body(p + 1, tupdesc, retval); return retval; } @@ -469,6 +671,115 @@ plphp_array_get_elem(zval *array, char *key) return zend_symtable_str_find(Z_ARRVAL_P(array), key, strlen(key)); } +/* + * plphp_build_record_literal + * Render a PHP array as a composite value's text form, "(f1,f2,...)". + * + * Fields are looked up by column name; if none of the names are present, + * the array's first N elements are used positionally (mirroring + * plphp_htup_from_zval). Every non-null field is emitted double-quoted + * with embedded quotes and backslashes doubled, which the record input + * function accepts for any type; nulls become empty fields. Array- and + * composite-typed fields are rendered recursively. + */ +static char * +plphp_build_record_literal(zval *val, TupleDesc tupdesc) +{ + StringInfoData str; + bool havenames = false; + int i; + int pos = 0; + bool first = true; + + if (Z_TYPE_P(val) != IS_ARRAY) + elog(ERROR, "composite value must be a PHP array"); + + /* do any of the column names appear as keys? */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + if (zend_symtable_str_find(Z_ARRVAL_P(val), NameStr(att->attname), + strlen(NameStr(att->attname))) != NULL) + { + havenames = true; + break; + } + } + + initStringInfo(&str); + appendStringInfoChar(&str, '('); + + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + zval *el; + char *fieldstr; + + if (att->attisdropped) + continue; + + if (!first) + appendStringInfoChar(&str, ','); + first = false; + + if (havenames) + el = zend_symtable_str_find(Z_ARRVAL_P(val), + NameStr(att->attname), + strlen(NameStr(att->attname))); + else + el = zend_hash_index_find(Z_ARRVAL_P(val), pos); + pos++; + + fieldstr = plphp_zval_get_typed_cstring(el, att->atttypid); + if (fieldstr == NULL) + continue; /* NULL: empty field */ + + appendStringInfoChar(&str, '"'); + for (const char *c = fieldstr; *c; c++) + { + if (*c == '"' || *c == '\\') + appendStringInfoChar(&str, *c); + appendStringInfoChar(&str, *c); + } + appendStringInfoChar(&str, '"'); + pfree(fieldstr); + } + + appendStringInfoChar(&str, ')'); + return str.data; +} + +/* + * plphp_zval_get_typed_cstring + * Like plphp_zval_get_cstring (null_ok semantics), but renders a PHP + * array destined for a composite-typed slot as a record literal, and + * threads the element type into array rendering so composites nest. + */ +char * +plphp_zval_get_typed_cstring(zval *val, Oid typid) +{ + if (val != NULL && Z_TYPE_P(val) == IS_ARRAY) + { + Oid elemtype = get_element_type(typid); + + if (get_typtype(typid) == TYPTYPE_COMPOSITE) + { + TupleDesc td = lookup_rowtype_tupdesc(typid, -1); + char *ret = plphp_build_record_literal(val, td); + + ReleaseTupleDesc(td); + return ret; + } + if (OidIsValid(elemtype)) + return plphp_convert_to_pg_array_typed(val, elemtype); + } + + return plphp_zval_get_cstring(val, true, true); +} + /* * zval_get_cstring * Get a C-string representation of a zval. @@ -666,7 +977,7 @@ plphp_modify_tuple(zval *outdata, TriggerData *tdata) elog(ERROR, "$_TD['new'] does not contain attribute \"%s\"", attname); - vals[i] = plphp_zval_get_cstring(el, true, true); + vals[i] = plphp_zval_get_typed_cstring(el, att->atttypid); } /* Return to the original context so that the new tuple will survive */ diff --git a/plphp_io.h b/plphp_io.h index d8af79e..749f246 100644 --- a/plphp_io.h +++ b/plphp_io.h @@ -44,7 +44,10 @@ HeapTuple plphp_htup_from_zval(zval *val, TupleDesc tupdesc); HeapTuple plphp_srf_htup_from_zval(zval *val, AttInMetadata *attinmeta, MemoryContext cxt); char *plphp_convert_to_pg_array(zval *array); -zval *plphp_convert_from_pg_array(char *input); +char *plphp_convert_to_pg_array_typed(zval *array, Oid elemtype); +zval *plphp_convert_from_pg_array(char *input, Oid elemtype); +zval *plphp_convert_from_pg_record(const char *input, TupleDesc tupdesc); +char *plphp_zval_get_typed_cstring(zval *val, Oid typid); zval *plphp_array_get_elem(zval* array, char *key); char *plphp_zval_get_cstring(zval *val, bool do_array, bool null_ok); zval *plphp_build_tuple_argument(HeapTuple tuple, TupleDesc tupdesc); diff --git a/sql/arrays.sql b/sql/arrays.sql index a551b12..9ff9acb 100644 --- a/sql/arrays.sql +++ b/sql/arrays.sql @@ -124,3 +124,77 @@ select arr_in_comp(row('x', array[5, 6, 7])::with_arr); drop function arr_in_row(), arr_in_cursor(), arr_in_comp(with_arr), arr_trig(); drop type with_arr; drop table arrt; + +-- composite conversion: composite columns/fields/elements are associative +-- arrays in both directions +create type arr_addr as (street text, city text); +create type arr_person as (name text, home arr_addr, tags text[]); +create table arr_folks (id int, who arr_person); +insert into arr_folks values + (1, row('J''s "Place"', row('1 Main, Apt (2)', 'Olympia')::arr_addr, + array['a', 'b,c'])::arr_person); + +-- composite column in a row: nested composite and array fields convert +create function arr_rec_peek() returns text language plphp as $$ + $r = spi_exec("select * from arr_folks"); + $row = spi_fetch_row($r); + $w = $row['who']; + return gettype($w) . " | " . $w['name'] . " | " . $w['home']['city'] + . " | " . $w['tags'][1] . " | " . gettype($w['tags']); +$$; +select arr_rec_peek(); + +-- composite argument (with nesting) +create function arr_rec_arg(p arr_person) returns text language plphp as $$ + return $args[0]['home']['street'] . " / " . count($args[0]['tags']); +$$; +select arr_rec_arg(row('X', row('5 Oak', 'Tacoma')::arr_addr, array['q'])::arr_person); + +-- composites inside an array argument +create function arr_rec_elems(addrs arr_addr[]) returns text language plphp as $$ + return $args[0][1]['city']; +$$; +select arr_rec_elems(array[row('a', 'CityA')::arr_addr, row('b', 'City,B()')::arr_addr]); + +-- the reverse: build a nested composite from associative arrays +create function arr_rec_make() returns arr_person language plphp as $$ + return array( + 'name' => 'He said "hi"', + 'home' => array('street' => '9 Elm, S(2)', 'city' => 'B\\C'), + 'tags' => array('x', 'y z') + ); +$$; +select * from arr_rec_make(); +select (arr_rec_make()).home.city, (arr_rec_make()).tags[2]; + +-- trigger MODIFY through a composite column +create function arr_rec_trig() returns trigger language plphp as $$ + $_TD['new']['who']['home']['city'] = 'Changed'; + return 'MODIFY'; +$$; +create trigger arr_folks_trg before insert on arr_folks + for each row execute procedure arr_rec_trig(); +insert into arr_folks values + (2, row('K', row('7 Pine', 'Old')::arr_addr, array['z'])::arr_person); +select (who).home.city from arr_folks where id = 2; +drop trigger arr_folks_trg on arr_folks; + +-- NULL fields inside composites survive both directions +create function arr_rec_nulls(p arr_addr) returns arr_addr language plphp as $$ + pg_raise('notice', 'street is ' . ($args[0]['street'] === null ? 'null' : 'set')); + return array('street' => null, 'city' => $args[0]['city']); +$$; +select * from arr_rec_nulls(row(NULL, 'Salem')::arr_addr); + +drop function arr_rec_peek(), arr_rec_arg(arr_person), arr_rec_elems(arr_addr[]), + arr_rec_make(), arr_rec_trig(), arr_rec_nulls(arr_addr); +drop table arr_folks; +drop type arr_person, arr_addr; + +-- anycompatible polymorphics (PostgreSQL 13+) +create function arr_first_compat(anycompatiblearray) returns anycompatible +language plphp as $$ + return $args[0][0]; +$$; +select arr_first_compat(array[7, 8, 9]); +select arr_first_compat(array['x', 'y']);