From 8f6e2a497d79d7a80f3881be010695d1f82577e7 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 6 Jul 2026 21:03:10 -0600 Subject: [PATCH] Add spi_colnames/spi_coltypes/spi_coltypmods Expose a SPI result's column metadata as parallel Arrays over its columns, the counterparts of PL/Python's colnames / coltypes / coltypmods: - spi_colnames(result) -> Array of column names (Strings) - spi_coltypes(result) -> Array of column type OIDs (Integers) - spi_coltypmods(result)-> Array of column type modifiers (Integers) Read from the result's tuple descriptor, skipping dropped columns. A result with no tuple set (a non-SELECT, e.g. a plain INSERT) has no columns, so each returns an empty Array -- consistent with spi_fetch_row, which already only yields rows for SELECT results. New sql/colmeta test; full suite green on PG 12 and 18. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++ Makefile | 2 +- doc/comparison.md | 1 + doc/plruby.md | 10 ++++++ expected/colmeta.out | 47 +++++++++++++++++++++++++++++ plruby_spi.c | 72 ++++++++++++++++++++++++++++++++++++++++++++ sql/colmeta.sql | 35 +++++++++++++++++++++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 expected/colmeta.out create mode 100644 sql/colmeta.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6e359..68a2554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). 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. +- **SPI result column metadata.** `spi_colnames(result)`, `spi_coltypes(result)`, + and `spi_coltypmods(result)` return parallel `Array`s of a result's column + names, type OIDs, and type modifiers, the counterparts of PL/Python's + `colnames` / `coltypes` / `coltypmods`. A non-`SELECT` result returns empty + `Array`s. ### Changed diff --git a/Makefile b/Makefile index d403d7d..4283b5d 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 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 +REGRESS = init base types numspecial bytea composite encoding datetime jsonb misc variadic shared sd trigger trigger2 spi colmeta 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 d0a0a04..81779ac 100644 --- a/doc/comparison.md +++ b/doc/comparison.md @@ -37,6 +37,7 @@ Legend: ✅ supported · ➖ not applicable / different mechanism · ❌ not pro | Iterate rows | `spi_fetch_row` to `Hash`/`nil` | `spi_fetch_row` | `spi_fetchrow` | row loop / `-array` | | Stream via cursor | `spi_query`(+block)/`spi_fetchrow`/`spi_cursor_close`, `Cursor#each` | ➖ | `spi_query`/`spi_fetchrow`/`spi_cursor_close` | `spi_exec`/cursors | | Rows processed / status / rewind | `spi_processed`/`spi_status`/`spi_rewind` | ✅ same | ➖ (return hash) | ➖ | +| Result column metadata | `spi_colnames`/`spi_coltypes`/`spi_coltypmods` | ➖ | ➖ | ➖ | | Prepare a plan | `spi_prepare(q, types...)` | ✅ same | `spi_prepare` | `spi_prepare` | | Execute a plan | `spi_exec_prepared` / `spi_query_prepared` | ✅ same | `spi_exec_prepared`/`spi_query_prepared` | `spi_execp` | | Free a plan | `spi_freeplan` | ✅ same | `spi_freeplan` | (auto) | diff --git a/doc/plruby.md b/doc/plruby.md index 79487b1..4818385 100644 --- a/doc/plruby.md +++ b/doc/plruby.md @@ -339,8 +339,18 @@ Run queries against the current database from within a function: rows are exhausted. - `spi_processed(result)`: number of rows the query produced. - `spi_status(result)`: the SPI status code as a `String`. +- `spi_colnames(result)`: an `Array` of the result's column names (`String`s). +- `spi_coltypes(result)`: an `Array` of the columns' type OIDs (`Integer`s); + cast one to a name with `oid::regtype` in SQL. +- `spi_coltypmods(result)`: an `Array` of the columns' type modifiers + (`Integer`s), e.g. `14` for `varchar(10)`, or `-1` when none applies. - `spi_rewind(result)`: restart iteration from the first row. +The three `spi_col*` accessors are the counterparts of PL/Python's `colnames`, +`coltypes`, and `coltypmods`; each returns parallel `Array`s over the result +columns. A result with no tuple set (a non-`SELECT`, such as a plain `INSERT`) +has no columns, so each returns an empty `Array`. + ```sql CREATE FUNCTION sum_series(n integer) RETURNS integer LANGUAGE plruby AS $$ r = spi_exec("select generate_series(1, #{args[0]}) as g") diff --git a/expected/colmeta.out b/expected/colmeta.out new file mode 100644 index 0000000..7a7180c --- /dev/null +++ b/expected/colmeta.out @@ -0,0 +1,47 @@ +-- +-- SPI result column metadata: spi_colnames / spi_coltypes / spi_coltypmods, +-- the counterparts of PL/Python's colnames / coltypes / coltypmods. Each is a +-- parallel Array over the result's columns; type OIDs are Integers. +-- +-- A SELECT result exposes its column names, type OIDs, and type modifiers. +-- (int4 = 23, text = 25, varchar = 1043; varchar(10) typmod = 14 = 10 + 4.) +CREATE FUNCTION colmeta() RETURNS text LANGUAGE plruby AS $$ + r = spi_exec("SELECT 1::int AS id, 'hi'::text AS label, 'ab'::varchar(10) AS code") + "names=#{spi_colnames(r).inspect} " + + "types=#{spi_coltypes(r).inspect} " + + "typmods=#{spi_coltypmods(r).inspect}" +$$; +SELECT colmeta(); + colmeta +------------------------------------------------------------------------- + names=["id", "label", "code"] types=[23, 25, 1043] typmods=[-1, -1, 14] +(1 row) + +-- The OIDs are usable: map them back to type names through the catalog. +CREATE FUNCTION colmeta_typenames() RETURNS text[] LANGUAGE plruby AS $$ + r = spi_exec("SELECT 1::int AS id, 'ab'::varchar(10) AS code") + spi_coltypes(r).map do |oid| + t = spi_exec("SELECT #{oid}::regtype::text AS n") + spi_fetch_row(t)['n'] + end +$$; +SELECT colmeta_typenames(); + colmeta_typenames +------------------------------- + {integer,"character varying"} +(1 row) + +-- A non-SELECT (no tuple table) has no columns: each metadata Array is empty. +CREATE FUNCTION colmeta_ddl() RETURNS text LANGUAGE plruby AS $$ + spi_exec("CREATE TEMP TABLE t_cm(x int)") + r = spi_exec("INSERT INTO t_cm VALUES (1)") + "names=#{spi_colnames(r).inspect} " + + "types=#{spi_coltypes(r).inspect} " + + "processed=#{spi_processed(r)}" +$$; +SELECT colmeta_ddl(); + colmeta_ddl +------------------------------- + names=[] types=[] processed=1 +(1 row) + diff --git a/plruby_spi.c b/plruby_spi.c index 4c44885..43273e5 100644 --- a/plruby_spi.c +++ b/plruby_spi.c @@ -477,6 +477,72 @@ plruby_spi_status(VALUE self, VALUE res) return rb_str_new_cstr(SPI_result_code_string(r->status)); } +/* + * Column metadata for a result's tuple descriptor, as parallel Arrays over the + * (non-dropped) result columns -- the counterparts of PL/Python's colnames / + * coltypes / coltypmods. A result without a tuple table (a non-SELECT, like a + * plain INSERT) has no columns, so each returns an empty Array. + */ +typedef enum +{ + PLRUBY_COL_NAME, + PLRUBY_COL_TYPE, + PLRUBY_COL_TYPMOD +} plruby_col_kind; + +static VALUE +plruby_spi_colmeta(VALUE res, plruby_col_kind kind) +{ + plruby_spi_result *r = plruby_get_spi_result(res); + VALUE out = rb_ary_new(); + TupleDesc tupdesc; + int i; + + if (r->tuptable == NULL) + return out; + + tupdesc = r->tuptable->tupdesc; + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + + switch (kind) + { + case PLRUBY_COL_NAME: + rb_ary_push(out, rb_str_new_cstr(NameStr(att->attname))); + break; + case PLRUBY_COL_TYPE: + rb_ary_push(out, UINT2NUM(att->atttypid)); + break; + case PLRUBY_COL_TYPMOD: + rb_ary_push(out, INT2NUM(att->atttypmod)); + break; + } + } + return out; +} + +static VALUE +plruby_spi_colnames(VALUE self, VALUE res) +{ + return plruby_spi_colmeta(res, PLRUBY_COL_NAME); +} + +static VALUE +plruby_spi_coltypes(VALUE self, VALUE res) +{ + return plruby_spi_colmeta(res, PLRUBY_COL_TYPE); +} + +static VALUE +plruby_spi_coltypmods(VALUE self, VALUE res) +{ + return plruby_spi_colmeta(res, PLRUBY_COL_TYPMOD); +} + static VALUE plruby_spi_rewind(VALUE self, VALUE res) { @@ -1277,6 +1343,12 @@ plruby_spi_init(void) RUBY_METHOD_FUNC(plruby_spi_processed), 1); rb_define_global_function("spi_status", RUBY_METHOD_FUNC(plruby_spi_status), 1); + rb_define_global_function("spi_colnames", + RUBY_METHOD_FUNC(plruby_spi_colnames), 1); + rb_define_global_function("spi_coltypes", + RUBY_METHOD_FUNC(plruby_spi_coltypes), 1); + rb_define_global_function("spi_coltypmods", + RUBY_METHOD_FUNC(plruby_spi_coltypmods), 1); rb_define_global_function("spi_rewind", RUBY_METHOD_FUNC(plruby_spi_rewind), 1); diff --git a/sql/colmeta.sql b/sql/colmeta.sql new file mode 100644 index 0000000..ee67eae --- /dev/null +++ b/sql/colmeta.sql @@ -0,0 +1,35 @@ +-- +-- SPI result column metadata: spi_colnames / spi_coltypes / spi_coltypmods, +-- the counterparts of PL/Python's colnames / coltypes / coltypmods. Each is a +-- parallel Array over the result's columns; type OIDs are Integers. +-- + +-- A SELECT result exposes its column names, type OIDs, and type modifiers. +-- (int4 = 23, text = 25, varchar = 1043; varchar(10) typmod = 14 = 10 + 4.) +CREATE FUNCTION colmeta() RETURNS text LANGUAGE plruby AS $$ + r = spi_exec("SELECT 1::int AS id, 'hi'::text AS label, 'ab'::varchar(10) AS code") + "names=#{spi_colnames(r).inspect} " + + "types=#{spi_coltypes(r).inspect} " + + "typmods=#{spi_coltypmods(r).inspect}" +$$; +SELECT colmeta(); + +-- The OIDs are usable: map them back to type names through the catalog. +CREATE FUNCTION colmeta_typenames() RETURNS text[] LANGUAGE plruby AS $$ + r = spi_exec("SELECT 1::int AS id, 'ab'::varchar(10) AS code") + spi_coltypes(r).map do |oid| + t = spi_exec("SELECT #{oid}::regtype::text AS n") + spi_fetch_row(t)['n'] + end +$$; +SELECT colmeta_typenames(); + +-- A non-SELECT (no tuple table) has no columns: each metadata Array is empty. +CREATE FUNCTION colmeta_ddl() RETURNS text LANGUAGE plruby AS $$ + spi_exec("CREATE TEMP TABLE t_cm(x int)") + r = spi_exec("INSERT INTO t_cm VALUES (1)") + "names=#{spi_colnames(r).inspect} " + + "types=#{spi_coltypes(r).inspect} " + + "processed=#{spi_processed(r)}" +$$; +SELECT colmeta_ddl();