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();