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']);