From 36929d02499e6db97d2e0e2412ff515674354581 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 6 Jul 2026 21:08:54 -0600 Subject: [PATCH] Add ltree_plruby: TRANSFORM FOR TYPE ltree A new companion extension (ltree_plruby/) providing TRANSFORM FOR TYPE ltree, mirroring the in-core ltree_plpython transform (ltree <-> list). An opted-in function receives an ltree argument as a Ruby Array of its label Strings ('Top.Science' -> ['Top','Science']) and may return an Array of labels into an ltree result (elements stringified and joined with '.', so an invalid label is rejected by ltree's own parser). The empty ltree maps to the empty Array; a NULL ltree is nil. Like hstore_plruby, it never touches ltree's internal representation: it converts through ltree's public ltree2text(ltree)/text2ltree(text) functions, resolved at run time via the pg_transform row that points at the transform function (schema/search_path independent) and cached in fn_extra. So it needs no ltree headers and works with the packaged ltree on PG 11-18. New ltree_plruby regression suite (arg->Array, path ops, build/round-trip, empty and NULL, non-Array rejection, SETOF/composite via return_next, trigger $_TD + MODIFY, untransformed passthrough). Wired into CI beside the jsonb/hstore transforms; INSTALL/README/docs updated. Green on PG 12 and 18. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 10 ++ CHANGELOG.md | 7 + INSTALL | 16 ++- README.md | 2 +- doc/plruby.md | 20 +++ ltree_plruby/Makefile | 29 ++++ ltree_plruby/expected/ltree_plruby.out | 166 ++++++++++++++++++++++ ltree_plruby/ltree_plruby--1.0.sql | 27 ++++ ltree_plruby/ltree_plruby.c | 185 +++++++++++++++++++++++++ ltree_plruby/ltree_plruby.control | 6 + ltree_plruby/sql/ltree_plruby.sql | 109 +++++++++++++++ 11 files changed, 569 insertions(+), 8 deletions(-) create mode 100644 ltree_plruby/Makefile create mode 100644 ltree_plruby/expected/ltree_plruby.out create mode 100644 ltree_plruby/ltree_plruby--1.0.sql create mode 100644 ltree_plruby/ltree_plruby.c create mode 100644 ltree_plruby/ltree_plruby.control create mode 100644 ltree_plruby/sql/ltree_plruby.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4844a03..8b147bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,8 @@ jobs: sudo make -C jsonb_plruby PG_CONFIG="$PG_CONFIG" RUBY="$RUBY" install make -C hstore_plruby PG_CONFIG="$PG_CONFIG" RUBY="$RUBY" sudo make -C hstore_plruby PG_CONFIG="$PG_CONFIG" RUBY="$RUBY" install + make -C ltree_plruby PG_CONFIG="$PG_CONFIG" RUBY="$RUBY" + sudo make -C ltree_plruby PG_CONFIG="$PG_CONFIG" RUBY="$RUBY" install - name: Start a test cluster run: | @@ -79,6 +81,12 @@ jobs: env: PGPORT: '5555' + - name: Run the ltree_plruby regression suite + run: | + make -C ltree_plruby PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config installcheck + env: + PGPORT: '5555' + - name: Show regression diffs on failure if: failure() run: | @@ -88,3 +96,5 @@ jobs: cat jsonb_plruby/regression.diffs 2>/dev/null || echo "(none)" echo "===== hstore_plruby regression.diffs =====" cat hstore_plruby/regression.diffs 2>/dev/null || echo "(none)" + echo "===== ltree_plruby regression.diffs =====" + cat ltree_plruby/regression.diffs 2>/dev/null || echo "(none)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a2554..955d944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). ### Added +- **`ltree_plruby`**: a companion extension (in `ltree_plruby/`) providing + `TRANSFORM FOR TYPE ltree`: opted-in functions receive ltree arguments as a + Ruby `Array` of label Strings and may return an `Array` of labels into an + ltree result (elements stringified and joined with `.`; the empty ltree is the + empty `Array`). Mirrors the in-core `ltree_plpython` transform. Implemented + against ltree's public `ltree2text`/`text2ltree` functions, so it needs no + ltree headers and works with the packaged ltree on PostgreSQL 11-18. - **`$_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 diff --git a/INSTALL b/INSTALL index e42202a..630da97 100644 --- a/INSTALL +++ b/INSTALL @@ -53,19 +53,21 @@ README for why. make installcheck -5. The jsonb and hstore transforms (optional) ---------------------------------------------- +5. The jsonb, hstore, and ltree transforms (optional) +----------------------------------------------------- -The jsonb_plruby/ and hstore_plruby/ subdirectories each build a separate -extension providing TRANSFORM FOR TYPE jsonb (native Ruby Hashes/Arrays) -and TRANSFORM FOR TYPE hstore (Ruby Hash of String => String/nil): +The jsonb_plruby/, hstore_plruby/, and ltree_plruby/ subdirectories each +build a separate extension providing TRANSFORM FOR TYPE jsonb (native Ruby +Hashes/Arrays), TRANSFORM FOR TYPE hstore (Ruby Hash of String => String/nil), +and TRANSFORM FOR TYPE ltree (Ruby Array of label Strings): - cd jsonb_plruby # or hstore_plruby + cd jsonb_plruby # or hstore_plruby, or ltree_plruby make sudo make install # in a database that has (or will get) plruby: # CREATE EXTENSION jsonb_plruby CASCADE; # CREATE EXTENSION hstore_plruby CASCADE; -- also requires hstore + # CREATE EXTENSION ltree_plruby CASCADE; -- also requires ltree -Both accept the same PG_CONFIG/RUBY overrides and have their own +All three accept the same PG_CONFIG/RUBY overrides and have their own "make installcheck". diff --git a/README.md b/README.md index 2d26bf7..0114fab 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ SELECT hello('world'); -- Hello, world! | 🔐 **Transaction control** | `spi_commit` / `spi_rollback` in procedures, plus `subtransaction` blocks. | | 🧰 **Utilities** | `quote_literal` / `quote_nullable` / `quote_ident`, `elog`, `$_SHARED`. | | 📦 **Session setup** | Anonymous `DO` blocks, `plruby_modules` autoloading, and a `plruby.start_proc` hook. | -| 🔄 **Transforms** | `jsonb_plruby` and `hstore_plruby`: functions declared `TRANSFORM FOR TYPE` exchange native Ruby Hashes/Arrays with `jsonb` and `hstore`. | +| 🔄 **Transforms** | `jsonb_plruby`, `hstore_plruby`, and `ltree_plruby`: functions declared `TRANSFORM FOR TYPE` exchange native Ruby Hashes/Arrays with `jsonb`, `hstore`, and `ltree`. | See the [**language reference**](doc/plruby.md) for the full API, the [**cookbook**](doc/cookbook.md) for tested recipes, and the diff --git a/doc/plruby.md b/doc/plruby.md index 4818385..c3f5880 100644 --- a/doc/plruby.md +++ b/doc/plruby.md @@ -204,6 +204,26 @@ to String-or-`nil` values, and a returned `Hash` becomes an hstore (keys and values are stringified; `nil` becomes an hstore `NULL`). It requires the `hstore` extension and is built from the `hstore_plruby/` subdirectory. +The **`ltree_plruby`** extension does the same for `ltree` +(`TRANSFORM FOR TYPE ltree`): an ltree argument arrives as a Ruby `Array` of +its label Strings (`'Top.Science'` becomes `['Top', 'Science']`), and a +returned `Array` becomes an ltree (its elements are stringified and joined +with `.`, so an invalid label is rejected by ltree's own parser). The empty +ltree maps to the empty `Array`. It mirrors the in-core `ltree_plpython` +transform, requires the `ltree` extension, and is built from the +`ltree_plruby/` subdirectory. + +```sql +CREATE EXTENSION ltree_plruby; -- requires plruby and ltree + +CREATE FUNCTION ancestors(path ltree) RETURNS SETOF ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + (1..path.length).each { |n| return_next(path[0...n]) } + nil +$$; +``` + ## Arguments PL/Ruby supports the full range of argument modes. diff --git a/ltree_plruby/Makefile b/ltree_plruby/Makefile new file mode 100644 index 0000000..f66d866 --- /dev/null +++ b/ltree_plruby/Makefile @@ -0,0 +1,29 @@ +# ltree_plruby - transform between ltree and Ruby Arrays for PL/Ruby +# +# Build after (or alongside) the plruby extension in the parent directory; +# the transform requires the ltree and plruby extensions at CREATE +# EXTENSION time. + +MODULE_big = ltree_plruby +OBJS = ltree_plruby.o + +EXTENSION = ltree_plruby +DATA = ltree_plruby--1.0.sql + +# Ruby compile/link flags, discovered via RbConfig (same as ../Makefile). +RUBY ?= ruby +RUBY_ARCHHDRDIR := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["rubyarchhdrdir"]') +RUBY_HDRDIR := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["rubyhdrdir"]') +RUBY_LIBDIR := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["libdir"]') +RUBY_ARCHLIBDIR := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["archlibdir"]') +RUBY_LIBARG := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["LIBRUBYARG_SHARED"]') +RUBY_LIBS := $(shell $(RUBY) -rrbconfig -e 'print RbConfig::CONFIG["LIBS"]') + +PG_CPPFLAGS = -I$(RUBY_ARCHHDRDIR) -I$(RUBY_HDRDIR) +SHLIB_LINK = -L$(RUBY_LIBDIR) -L$(RUBY_ARCHLIBDIR) $(RUBY_LIBARG) $(RUBY_LIBS) + +REGRESS = ltree_plruby + +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/ltree_plruby/expected/ltree_plruby.out b/ltree_plruby/expected/ltree_plruby.out new file mode 100644 index 0000000..5933fcd --- /dev/null +++ b/ltree_plruby/expected/ltree_plruby.out @@ -0,0 +1,166 @@ +-- +-- ltree <-> Ruby Array transform. Functions must opt in with +-- TRANSFORM FOR TYPE ltree. An ltree arrives as an Array of its label +-- Strings, and an Array of labels is joined back into an ltree. +-- +CREATE EXTENSION ltree_plruby CASCADE; +NOTICE: installing required extension "ltree" +NOTICE: installing required extension "plruby" +-- An ltree argument arrives as an Array of label Strings. +CREATE FUNCTION lt_labels(ltree) RETURNS text +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + "#{args[0].class.name}: #{args[0].inspect}" +$$; +SELECT lt_labels('Top.Science.Astronomy'::ltree); + lt_labels +---------------------------------------- + Array: ["Top", "Science", "Astronomy"] +(1 row) + +-- Array operations map onto path operations. +CREATE FUNCTION lt_nlabels(ltree) RETURNS int +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0].length +$$; +SELECT lt_nlabels('a.b.c'::ltree); + lt_nlabels +------------ + 3 +(1 row) + +SELECT lt_nlabels(''::ltree); + lt_nlabels +------------ + 0 +(1 row) + +CREATE FUNCTION lt_parent(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0][0...-1] +$$; +SELECT lt_parent('a.b.c'::ltree); + lt_parent +----------- + a.b +(1 row) + +CREATE FUNCTION lt_reverse(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0].reverse +$$; +SELECT lt_reverse('a.b.c'::ltree); + lt_reverse +------------ + c.b.a +(1 row) + +-- Build an ltree from Ruby data: elements are stringified before joining. +CREATE FUNCTION lt_build(int) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + ['node', args[0], args[0] * 2] +$$; +SELECT lt_build(3); + lt_build +---------- + node.3.6 +(1 row) + +-- The empty ltree round-trips as the empty Array. +CREATE FUNCTION lt_echo(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0] +$$; +SELECT lt_echo(''::ltree) = ''::ltree AS empty_roundtrip; + empty_roundtrip +----------------- + t +(1 row) + +-- A NULL ltree argument is nil; returning nil is SQL NULL. +SELECT lt_echo(NULL) IS NULL AS null_roundtrip; + null_roundtrip +---------------- + t +(1 row) + +-- Returning something other than an Array is rejected cleanly. +CREATE FUNCTION lt_bad() RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + 'not an array' +$$; +SELECT lt_bad(); +ERROR: cannot transform Ruby object of class String to ltree +HINT: An ltree value is built from an Array of labels. +CONTEXT: PL/Ruby function "lt_bad" +-- The transform reaches nested contexts: SETOF rows via return_next... +CREATE FUNCTION lt_ancestors(ltree) RETURNS SETOF ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + labels = args[0] + (1..labels.length).each { |n| return_next(labels[0...n]) } + nil +$$; +SELECT * FROM lt_ancestors('a.b.c'::ltree); + lt_ancestors +-------------- + a + a.b + a.b.c +(3 rows) + +-- ...and composite rows with an ltree column. +CREATE FUNCTION lt_rows(ltree) RETURNS TABLE (depth int, path ltree) +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + labels = args[0] + (1..labels.length).each do |n| + return_next({'depth' => n, 'path' => labels[0...n]}) + end + nil +$$; +SELECT * FROM lt_rows('x.y'::ltree); + depth | path +-------+------ + 1 | x + 2 | x.y +(2 rows) + +-- Triggers that declare the transform get $_TD ltree columns as Arrays, +-- and 'MODIFY' accepts an Array back. +CREATE TABLE lt_items (id int, path ltree); +CREATE FUNCTION lt_normalize() RETURNS trigger +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + p = $_TD['new']['path'] + elog('NOTICE', "path is a #{p.class.name}: #{p.inspect}") + $_TD['new']['path'] = p.map(&:downcase) + 'MODIFY' +$$; +CREATE TRIGGER lt_items_norm BEFORE INSERT ON lt_items + FOR EACH ROW EXECUTE PROCEDURE lt_normalize(); +INSERT INTO lt_items VALUES (1, 'Top.Science'); +NOTICE: path is a Array: ["Top", "Science"] +SELECT id, path FROM lt_items; + id | path +----+------------- + 1 | top.science +(1 row) + +DROP TABLE lt_items; +-- Without the TRANSFORM clause, ltree still travels as a String. +CREATE FUNCTION lt_untransformed(ltree) RETURNS text LANGUAGE plruby AS $$ + "#{args[0].class.name}: #{args[0]}" +$$; +SELECT lt_untransformed('a.b'::ltree); + lt_untransformed +------------------ + String: a.b +(1 row) + diff --git a/ltree_plruby/ltree_plruby--1.0.sql b/ltree_plruby/ltree_plruby--1.0.sql new file mode 100644 index 0000000..28c8dce --- /dev/null +++ b/ltree_plruby/ltree_plruby--1.0.sql @@ -0,0 +1,27 @@ +/* Transform between ltree and Ruby Arrays for PL/Ruby. + * + * With this transform, a function created with TRANSFORM FOR TYPE ltree + * receives ltree arguments as a Ruby Array of label Strings, and may return + * an Array of labels into an ltree result. + */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION ltree_plruby" to load this file. \quit + +CREATE FUNCTION ltree_to_plruby(val internal) +RETURNS internal +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT IMMUTABLE; + +CREATE FUNCTION plruby_to_ltree(val internal) +RETURNS ltree +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT IMMUTABLE; + +CREATE TRANSFORM FOR ltree LANGUAGE plruby ( + FROM SQL WITH FUNCTION ltree_to_plruby(internal), + TO SQL WITH FUNCTION plruby_to_ltree(internal) +); + +COMMENT ON TRANSFORM FOR ltree LANGUAGE plruby + IS 'transform between ltree and Ruby Arrays'; diff --git a/ltree_plruby/ltree_plruby.c b/ltree_plruby/ltree_plruby.c new file mode 100644 index 0000000..86e7d92 --- /dev/null +++ b/ltree_plruby/ltree_plruby.c @@ -0,0 +1,185 @@ +/********************************************************************** + * ltree_plruby.c - TRANSFORM between ltree and Ruby Arrays for PL/Ruby. + * + * ltree_to_plruby (FROM SQL) converts an ltree datum into a Ruby Array of + * its label Strings ('Top.Science.Astronomy' -> ['Top','Science', + * 'Astronomy']). plruby_to_ltree (TO SQL) converts a Ruby Array back: + * elements are stringified and joined with '.', then parsed as an ltree. + * This mirrors the in-core ltree_plpython transform (ltree <-> list). + * + * Like hstore_plruby, this module never touches ltree's internal + * representation: it converts through the ltree extension's own + * SQL-callable functions, ltree2text(ltree) and text2ltree(text), whose + * OIDs are resolved at run time. The ltree type is found through the + * pg_transform row that points at this very function, so the lookup is + * independent of schemas and search_path. The resolved OID is cached in + * fn_extra. + * + * The Ruby VM is owned and initialized by plruby; these functions only run + * inside a PL/Ruby function call, so it is always up. + * + * Copyright (c) 2026 ChronicallyJD. MIT License; see LICENSE. + **********************************************************************/ + +#include "postgres.h" + +#include +#include + +#include "access/genam.h" +#include "access/htup_details.h" +#if PG_VERSION_NUM >= 120000 +#include "access/table.h" +#else +#include "access/heapam.h" +#define table_open(rel, lock) heap_open(rel, lock) +#define table_close(rel, lock) heap_close(rel, lock) +#endif +#include "catalog/pg_proc.h" +#include "catalog/pg_transform.h" +#include "catalog/pg_type.h" +#include "fmgr.h" +#include "mb/pg_wchar.h" +#include "utils/builtins.h" +#include "utils/syscache.h" + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(ltree_to_plruby); +PG_FUNCTION_INFO_V1(plruby_to_ltree); + +/* + * The ltree type OID, found via the pg_transform row whose FromSQL (or + * ToSQL) function is this transform function itself. + */ +static Oid +ltree_type_from_transform(Oid trf_fn, bool fromsql) +{ + Relation rel; + SysScanDesc scan; + HeapTuple tup; + Oid result = InvalidOid; + + rel = table_open(TransformRelationId, AccessShareLock); + scan = systable_beginscan(rel, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Form_pg_transform t = (Form_pg_transform) GETSTRUCT(tup); + + if ((fromsql ? t->trffromsql : t->trftosql) == trf_fn) + { + result = t->trftype; + break; + } + } + systable_endscan(scan); + table_close(rel, AccessShareLock); + + if (!OidIsValid(result)) + elog(ERROR, "could not find pg_transform entry for function %u", trf_fn); + return result; +} + +/* + * The OID of an ltree-extension function living in the ltree type's own + * namespace: ltree2text(ltree) or text2ltree(text). + */ +static Oid +lookup_ltree_fn(Oid ltree_oid, const char *name, Oid argtype) +{ + HeapTuple typtup; + Oid nsp; + oidvector *argv; + HeapTuple ftup; + Oid result; + + typtup = SearchSysCache1(TYPEOID, ObjectIdGetDatum(ltree_oid)); + if (!HeapTupleIsValid(typtup)) + elog(ERROR, "cache lookup failed for type %u", ltree_oid); + nsp = ((Form_pg_type) GETSTRUCT(typtup))->typnamespace; + ReleaseSysCache(typtup); + + argv = buildoidvector(&argtype, 1); + ftup = SearchSysCache3(PROCNAMEARGSNSP, CStringGetDatum(name), + PointerGetDatum(argv), ObjectIdGetDatum(nsp)); + if (!HeapTupleIsValid(ftup)) + elog(ERROR, "could not find the ltree extension's %s function", name); +#if PG_VERSION_NUM >= 120000 + result = ((Form_pg_proc) GETSTRUCT(ftup))->oid; +#else + result = HeapTupleGetOid(ftup); +#endif + ReleaseSysCache(ftup); + return result; +} + +/* Resolve (once per FmgrInfo) the conversion function this direction uses. */ +static Oid +conversion_fn(FunctionCallInfo fcinfo, bool fromsql) +{ + Oid *cached = (Oid *) fcinfo->flinfo->fn_extra; + + if (cached == NULL) + { + Oid ltree_oid = ltree_type_from_transform(fcinfo->flinfo->fn_oid, + fromsql); + + cached = (Oid *) MemoryContextAlloc(fcinfo->flinfo->fn_mcxt, sizeof(Oid)); + if (fromsql) + *cached = lookup_ltree_fn(ltree_oid, "ltree2text", ltree_oid); + else + *cached = lookup_ltree_fn(ltree_oid, "text2ltree", TEXTOID); + fcinfo->flinfo->fn_extra = cached; + } + return *cached; +} + +/* + * A Ruby String in the database encoding: tag UTF-8 databases properly and + * leave anything else as binary (matching plruby's fallback behavior). + */ +static VALUE +lt_str_new(const char *ptr, long len) +{ + if (GetDatabaseEncoding() == PG_UTF8) + return rb_enc_str_new(ptr, len, rb_utf8_encoding()); + return rb_str_new(ptr, len); +} + +Datum +ltree_to_plruby(PG_FUNCTION_ARGS) +{ + Oid to_text = conversion_fn(fcinfo, true); + text *t; + VALUE path; + + /* ltree2text: 'a.b.c' (an empty ltree is the empty string) */ + t = DatumGetTextPP(OidFunctionCall1(to_text, PG_GETARG_DATUM(0))); + path = lt_str_new(VARDATA_ANY(t), VARSIZE_ANY_EXHDR(t)); + + /* Labels never contain '.', so a literal split yields the label Array; + * "".split('.') is [], so an empty ltree becomes an empty Array. */ + PG_RETURN_DATUM((Datum) rb_str_split(path, ".")); +} + +Datum +plruby_to_ltree(PG_FUNCTION_ARGS) +{ + Oid from_text = conversion_fn(fcinfo, false); + VALUE v = (VALUE) PG_GETARG_DATUM(0); + VALUE joined; + text *t; + + if (!RB_TYPE_P(v, T_ARRAY)) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("cannot transform Ruby object of class %s to ltree", + rb_obj_classname(v)), + errhint("An ltree value is built from an Array of labels."))); + + /* Array#join stringifies each element (Symbols, numbers, ...) with '.'. */ + joined = rb_ary_join(v, rb_str_new_cstr(".")); + t = cstring_to_text_with_len(RSTRING_PTR(joined), RSTRING_LEN(joined)); + + PG_RETURN_DATUM(OidFunctionCall1(from_text, PointerGetDatum(t))); +} diff --git a/ltree_plruby/ltree_plruby.control b/ltree_plruby/ltree_plruby.control new file mode 100644 index 0000000..3d05e54 --- /dev/null +++ b/ltree_plruby/ltree_plruby.control @@ -0,0 +1,6 @@ +# ltree transform for PL/Ruby (MIT License; see LICENSE) +comment = 'transform between ltree and Ruby Arrays' +default_version = '1.0' +module_pathname = '$libdir/ltree_plruby' +requires = 'ltree, plruby' +relocatable = true diff --git a/ltree_plruby/sql/ltree_plruby.sql b/ltree_plruby/sql/ltree_plruby.sql new file mode 100644 index 0000000..0ee6c86 --- /dev/null +++ b/ltree_plruby/sql/ltree_plruby.sql @@ -0,0 +1,109 @@ +-- +-- ltree <-> Ruby Array transform. Functions must opt in with +-- TRANSFORM FOR TYPE ltree. An ltree arrives as an Array of its label +-- Strings, and an Array of labels is joined back into an ltree. +-- +CREATE EXTENSION ltree_plruby CASCADE; + +-- An ltree argument arrives as an Array of label Strings. +CREATE FUNCTION lt_labels(ltree) RETURNS text +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + "#{args[0].class.name}: #{args[0].inspect}" +$$; +SELECT lt_labels('Top.Science.Astronomy'::ltree); + +-- Array operations map onto path operations. +CREATE FUNCTION lt_nlabels(ltree) RETURNS int +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0].length +$$; +SELECT lt_nlabels('a.b.c'::ltree); +SELECT lt_nlabels(''::ltree); + +CREATE FUNCTION lt_parent(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0][0...-1] +$$; +SELECT lt_parent('a.b.c'::ltree); + +CREATE FUNCTION lt_reverse(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0].reverse +$$; +SELECT lt_reverse('a.b.c'::ltree); + +-- Build an ltree from Ruby data: elements are stringified before joining. +CREATE FUNCTION lt_build(int) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + ['node', args[0], args[0] * 2] +$$; +SELECT lt_build(3); + +-- The empty ltree round-trips as the empty Array. +CREATE FUNCTION lt_echo(ltree) RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + args[0] +$$; +SELECT lt_echo(''::ltree) = ''::ltree AS empty_roundtrip; + +-- A NULL ltree argument is nil; returning nil is SQL NULL. +SELECT lt_echo(NULL) IS NULL AS null_roundtrip; + +-- Returning something other than an Array is rejected cleanly. +CREATE FUNCTION lt_bad() RETURNS ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + 'not an array' +$$; +SELECT lt_bad(); + +-- The transform reaches nested contexts: SETOF rows via return_next... +CREATE FUNCTION lt_ancestors(ltree) RETURNS SETOF ltree +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + labels = args[0] + (1..labels.length).each { |n| return_next(labels[0...n]) } + nil +$$; +SELECT * FROM lt_ancestors('a.b.c'::ltree); + +-- ...and composite rows with an ltree column. +CREATE FUNCTION lt_rows(ltree) RETURNS TABLE (depth int, path ltree) +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + labels = args[0] + (1..labels.length).each do |n| + return_next({'depth' => n, 'path' => labels[0...n]}) + end + nil +$$; +SELECT * FROM lt_rows('x.y'::ltree); + +-- Triggers that declare the transform get $_TD ltree columns as Arrays, +-- and 'MODIFY' accepts an Array back. +CREATE TABLE lt_items (id int, path ltree); +CREATE FUNCTION lt_normalize() RETURNS trigger +TRANSFORM FOR TYPE ltree +LANGUAGE plruby AS $$ + p = $_TD['new']['path'] + elog('NOTICE', "path is a #{p.class.name}: #{p.inspect}") + $_TD['new']['path'] = p.map(&:downcase) + 'MODIFY' +$$; +CREATE TRIGGER lt_items_norm BEFORE INSERT ON lt_items + FOR EACH ROW EXECUTE PROCEDURE lt_normalize(); +INSERT INTO lt_items VALUES (1, 'Top.Science'); +SELECT id, path FROM lt_items; +DROP TABLE lt_items; + +-- Without the TRANSFORM clause, ltree still travels as a String. +CREATE FUNCTION lt_untransformed(ltree) RETURNS text LANGUAGE plruby AS $$ + "#{args[0].class.name}: #{args[0]}" +$$; +SELECT lt_untransformed('a.b'::ltree);