diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c0f20..deec06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to PL/Ruby 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 + +- **`$_SD`: per-function static storage.** A `Hash` private to each function that + persists across calls to that function within a session, the counterpart of + PL/Python's `SD` (where `$_SHARED` is `GD`). Each function's `$_SD` is + independent and resets when the function is recompiled; an anonymous `DO` block + gets a fresh, empty `$_SD` each run. + ## [2.4.0] - 2026-07-06 Feature parity with the sibling PL/php 2.4: an `on_init` hook, the diff --git a/Makefile b/Makefile index 3eeeaee..d403d7d 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ PG_CPPFLAGS = -I$(RUBY_ARCHHDRDIR) -I$(RUBY_HDRDIR) SHLIB_LINK = -L$(RUBY_LIBDIR) -L$(RUBY_ARCHLIBDIR) $(RUBY_LIBARG) $(RUBY_LIBS) # Regression tests. "init" installs the extension; keep it first. -REGRESS = init base types numspecial bytea composite encoding datetime jsonb misc variadic shared trigger trigger2 spi raise errors sqlstate errcontext cargs pseudo anycompat srf out varnames validator replace classes prepare cursor hostile compat txn evttrig subxact modules quote require cookbook stdio startproc oninit +REGRESS = init base types numspecial bytea composite encoding datetime jsonb misc variadic shared sd trigger trigger2 spi raise errors sqlstate errcontext cargs pseudo anycompat srf out varnames validator replace classes prepare cursor hostile compat txn evttrig subxact modules quote require cookbook stdio startproc oninit PG_CONFIG ?= pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) diff --git a/doc/comparison.md b/doc/comparison.md index 771c33f..d0a0a04 100644 --- a/doc/comparison.md +++ b/doc/comparison.md @@ -23,6 +23,7 @@ Legend: ✅ supported · ➖ not applicable / different mechanism · ❌ not pro | Procedures + transaction control | ✅ `spi_commit`/`spi_rollback` | ✅ | ✅ | ✅ `commit`/`rollback` | | Explicit subtransactions | ✅ `subtransaction { }` | ✅ `subtransaction(callable)` | ➖ (`eval`/`spi_exec` traps) | ✅ `subtransaction { }` | | Session-shared data | ✅ `$_SHARED` | ✅ `$_SHARED` | ✅ `%_SHARED` | ✅ `GD` | +| Per-function static data | ✅ `$_SD` | ➖ | ➖ | ➖ | | Module autoloading | ✅ `plruby_modules` | ✅ `plphp_modules` | ➖ | ✅ `pltcl_modules` + `unknown` | | Start proc | ✅ `plruby.start_proc` | ✅ `plphp.start_proc` | ✅ `plperl.on_init` etc. | ✅ `pltcl.start_proc` | | Standard output forwarded to server log | ✅ `puts`/`print` | ✅ | ➖ (`elog`) | ➖ (`elog`) | diff --git a/doc/plruby.md b/doc/plruby.md index 0c8dd4a..84e67c7 100644 --- a/doc/plruby.md +++ b/doc/plruby.md @@ -30,6 +30,7 @@ non-thread-safe embedding). - [Quoting helpers](#quoting-helpers) - [Messaging: elog and pg_raise](#messaging-elog-and-pg_raise) - [Shared data: `$_SHARED`](#shared-data-_shared) +- [Per-function data: `$_SD`](#per-function-data-_sd) - [Session initialization: on_init, modules and start_proc](#session-initialization-on_init-modules-and-start_proc) - [Errors and exceptions](#errors-and-exceptions) - [Security](#security) @@ -505,6 +506,24 @@ CREATE FUNCTION get_shared(key text) RETURNS text LANGUAGE plruby AS $$ $$; ``` +## Per-function data: `$_SD` + +`$_SD` is a `Hash` private to each function that persists across calls to that +function within the same session. Where `$_SHARED` is one `Hash` shared by every +function (like PL/Python's `GD`), `$_SD` gives each function its own storage +(like PL/Python's `SD`), which is the natural place to cache a prepared plan or +other per-function state without risking a name collision with another function: + +```sql +CREATE FUNCTION counter() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +``` + +Each function's `$_SD` is independent, and it is reset when the function is +recompiled (`CREATE OR REPLACE`). An anonymous `DO` block gets a fresh, empty +`$_SD` each time it runs; `$_SHARED` remains shared everywhere. + ## Session initialization: on_init, modules and start_proc Three mechanisms let you run Ruby setup code the first time PL/Ruby is used in a diff --git a/expected/sd.out b/expected/sd.out new file mode 100644 index 0000000..12b2ed0 --- /dev/null +++ b/expected/sd.out @@ -0,0 +1,87 @@ +-- +-- $_SD: a Hash private to each function, persisting across calls to that +-- function within a session (counterpart of PL/Python's SD; $_SHARED is GD). +-- +-- Persists across calls to the same function. +CREATE FUNCTION sd_counter() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +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 different function has its own independent $_SD. +CREATE FUNCTION sd_counter2() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +SELECT sd_counter2(); + sd_counter2 +------------- + 1 +(1 row) + +SELECT sd_counter(); -- unaffected by sd_counter2, keeps counting + sd_counter +------------ + 4 +(1 row) + +-- $_SD is distinct from the session-global $_SHARED. +CREATE FUNCTION sd_vs_shared() RETURNS text LANGUAGE plruby AS $$ + $_SD['who'] = 'sd' + $_SHARED['who'] = 'shared' + "sd=#{$_SD['who']} shared=#{$_SHARED['who']}" +$$; +SELECT sd_vs_shared(); + sd_vs_shared +--------------------- + sd=sd shared=shared +(1 row) + +-- CREATE OR REPLACE resets $_SD (it is tied to the compiled function). +SELECT sd_counter(); -- still climbing before replace + sd_counter +------------ + 5 +(1 row) + +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +SELECT sd_counter(); -- back to 1 after replace + sd_counter +------------ + 1 +(1 row) + +-- $_SD survives even when a later call in the same function raises. +CREATE FUNCTION sd_stash_then_fail() RETURNS int LANGUAGE plruby AS $$ + $_SD['kept'] = 'yes' + raise 'deliberate failure' +$$; +SELECT sd_stash_then_fail(); +ERROR: deliberate failure +CONTEXT: PL/Ruby function "sd_stash_then_fail" +CREATE FUNCTION sd_read_kept() RETURNS text LANGUAGE plruby AS $$ + $_SD['kept'] || 'unset' +$$; +SELECT sd_read_kept(); -- its own $_SD: never set 'kept' + sd_read_kept +-------------- + unset +(1 row) + diff --git a/plruby.c b/plruby.c index 9a19245..5074ae2 100644 --- a/plruby.c +++ b/plruby.c @@ -109,6 +109,15 @@ static char *plruby_start_proc = NULL; static char *plruby_on_init = NULL; static bool plruby_session_inited = false; +/* + * Per-function static storage. Maps a function's internal proc name (see + * plruby_compile_function) to a Hash that is exposed to the body as $_SD and + * persists across calls to that function within the session. Counterpart of + * PL/Python's SD; $_SHARED is the session-global GD. Entries are dropped when + * a function is recompiled, so $_SD resets on CREATE OR REPLACE. + */ +static VALUE plruby_sd_table; + /* Forward declarations */ void _PG_init(void); void plruby_init(void); @@ -374,6 +383,10 @@ plruby_init(void) plruby_proc_cache = rb_hash_new(); rb_gc_register_address(&plruby_proc_cache); + plruby_sd_table = rb_hash_new(); + rb_gc_register_address(&plruby_sd_table); + rb_gv_set("$_SD", rb_hash_new()); /* placeholder until a body binds one */ + rb_gc_register_address(¤t_srf_binding); plruby_spi_init(); @@ -504,6 +517,29 @@ plruby_error_callback(void *arg) * Call handler * ------------------------------------------------------------------- */ +/* + * Bind $_SD to this function's private, session-persistent Hash and return the + * previous $_SD so the caller can restore it (needed when a body re-enters + * PL/Ruby via SPI). The hash is keyed by the internal proc name, so it is + * distinct for every function -- and for a function's trigger vs plain form -- + * and is dropped when the function is recompiled. + */ +static VALUE +plruby_bind_sd(plruby_proc_desc *desc) +{ + VALUE prev = rb_gv_get("$_SD"); + VALUE key = rb_str_new_cstr(desc->proname); + VALUE sd = rb_hash_aref(plruby_sd_table, key); + + if (NIL_P(sd)) + { + sd = rb_hash_new(); + rb_hash_aset(plruby_sd_table, key, sd); + } + rb_gv_set("$_SD", sd); + return prev; +} + Datum plruby_call_handler(PG_FUNCTION_ARGS) { @@ -537,6 +573,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) int save_call_ntrf = plruby_call_ntransforms; plruby_transform_info *save_arg_trf = plruby_arg_transforms; int save_arg_ntrf = plruby_arg_ntransforms; + VALUE save_sd = rb_gv_get("$_SD"); ErrorContextCallback ecb; plruby_error_ctx errctx; @@ -564,6 +601,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) desc = plruby_compile_function(fcinfo->flinfo->fn_oid, true, false); plruby_call_transforms = desc->transforms; plruby_call_ntransforms = desc->n_transforms; + plruby_bind_sd(desc); retval = plruby_trigger_handler(fcinfo, desc); } else if (CALLED_AS_EVENT_TRIGGER(fcinfo)) @@ -571,6 +609,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) desc = plruby_compile_function(fcinfo->flinfo->fn_oid, false, true); plruby_call_transforms = NULL; plruby_call_ntransforms = 0; + plruby_bind_sd(desc); retval = plruby_event_trigger_handler(fcinfo, desc); } else @@ -578,6 +617,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) desc = plruby_compile_function(fcinfo->flinfo->fn_oid, false, false); plruby_call_transforms = desc->transforms; plruby_call_ntransforms = desc->n_transforms; + plruby_bind_sd(desc); if (desc->retset) retval = plruby_srf_handler(fcinfo, desc); else @@ -591,6 +631,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) plruby_call_ntransforms = save_call_ntrf; plruby_arg_transforms = save_arg_trf; plruby_arg_ntransforms = save_arg_ntrf; + rb_gv_set("$_SD", save_sd); PG_RE_THROW(); } PG_END_TRY(); @@ -600,6 +641,7 @@ plruby_call_handler(PG_FUNCTION_ARGS) plruby_call_ntransforms = save_call_ntrf; plruby_arg_transforms = save_arg_trf; plruby_arg_ntransforms = save_arg_ntrf; + rb_gv_set("$_SD", save_sd); } return retval; @@ -1257,6 +1299,10 @@ plruby_compile_function(Oid fnoid, bool is_trigger, bool is_event_trigger) */ rb_hash_delete(plruby_proc_cache, rb_str_new_cstr(internal_proname)); + + /* Reset $_SD too, so a recompile starts with empty storage. */ + rb_hash_delete(plruby_sd_table, + rb_str_new_cstr(internal_proname)); } } } @@ -1715,6 +1761,7 @@ plruby_inline_handler(PG_FUNCTION_ARGS) StringInfoData src; int state = 0; VALUE params[2]; + VALUE save_sd; ErrorContextCallback ecb; plruby_error_ctx errctx; @@ -1759,7 +1806,16 @@ plruby_inline_handler(PG_FUNCTION_ARGS) params[0] = rb_ary_new(); params[1] = INT2NUM(0); + + /* + * An anonymous block has no persistent identity, so $_SD is a fresh empty + * Hash for the duration of the block (matching PL/Python, where a DO block + * gets its own SD that does not survive). $_SHARED still persists. + */ + save_sd = rb_gv_get("$_SD"); + rb_gv_set("$_SD", rb_hash_new()); plruby_protected_call(plruby_recv, rb_intern(funcname), 2, params); + rb_gv_set("$_SD", save_sd); plruby_remove_method(funcname); diff --git a/sql/sd.sql b/sql/sd.sql new file mode 100644 index 0000000..e6ee13a --- /dev/null +++ b/sql/sd.sql @@ -0,0 +1,45 @@ +-- +-- $_SD: a Hash private to each function, persisting across calls to that +-- function within a session (counterpart of PL/Python's SD; $_SHARED is GD). +-- + +-- Persists across calls to the same function. +CREATE FUNCTION sd_counter() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +SELECT sd_counter(); +SELECT sd_counter(); +SELECT sd_counter(); + +-- A different function has its own independent $_SD. +CREATE FUNCTION sd_counter2() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +SELECT sd_counter2(); +SELECT sd_counter(); -- unaffected by sd_counter2, keeps counting + +-- $_SD is distinct from the session-global $_SHARED. +CREATE FUNCTION sd_vs_shared() RETURNS text LANGUAGE plruby AS $$ + $_SD['who'] = 'sd' + $_SHARED['who'] = 'shared' + "sd=#{$_SD['who']} shared=#{$_SHARED['who']}" +$$; +SELECT sd_vs_shared(); + +-- CREATE OR REPLACE resets $_SD (it is tied to the compiled function). +SELECT sd_counter(); -- still climbing before replace +CREATE OR REPLACE FUNCTION sd_counter() RETURNS int LANGUAGE plruby AS $$ + $_SD['n'] = ($_SD['n'] || 0) + 1 +$$; +SELECT sd_counter(); -- back to 1 after replace + +-- $_SD survives even when a later call in the same function raises. +CREATE FUNCTION sd_stash_then_fail() RETURNS int LANGUAGE plruby AS $$ + $_SD['kept'] = 'yes' + raise 'deliberate failure' +$$; +SELECT sd_stash_then_fail(); +CREATE FUNCTION sd_read_kept() RETURNS text LANGUAGE plruby AS $$ + $_SD['kept'] || 'unset' +$$; +SELECT sd_read_kept(); -- its own $_SD: never set 'kept'