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