--
-- jsonb <-> Ruby transform.  Functions must opt in with
-- TRANSFORM FOR TYPE jsonb.
--
CREATE EXTENSION jsonb_plruby CASCADE;

-- A jsonb argument arrives as native Ruby data.
CREATE FUNCTION jt_classes(jsonb) RETURNS text
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    walk = lambda { |v|
        case v
        when Hash  then "{#{v.map { |k, e| "#{k}:#{walk.call(e)}" }.join(',')}}"
        when Array then "[#{v.map { |e| walk.call(e) }.join(',')}]"
        else "#{v.class.name}(#{v.inspect})"
        end
    }
    walk.call(args[0])
$$;
SELECT jt_classes('{"s": "text", "i": 5, "f": 2.5, "t": true, "n": null, "a": [1, "two"]}');

-- Raw scalars unwrap.
SELECT jt_classes('42');
SELECT jt_classes('"just a string"');
SELECT jt_classes('null');

-- Integral numbers arrive as Integer (not Float), big ones included.
CREATE FUNCTION jt_sum(jsonb) RETURNS text
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    total = args[0].sum
    "#{total.class.name}=#{total}"
$$;
SELECT jt_sum('[1, 2, 3]');
SELECT jt_sum('[9007199254740993, 1]');

-- Returning Ruby data into jsonb, no JSON.generate needed.
CREATE FUNCTION jt_build(int) RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    {'n' => args[0], 'squares' => (1..args[0]).map { |i| i * i },
     'odd' => args[0].odd?, 'label' => :generated, 'missing' => nil}
$$;
SELECT jt_build(3);
SELECT jt_build(4)->'squares'->2 AS third_square;

-- Round-trip fidelity through both directions.
CREATE FUNCTION jt_echo(jsonb) RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    args[0]
$$;
SELECT jt_echo('{"a": [1, 2.5, "x", true, null], "b": {"nested": {"deep": []}}}');
SELECT jt_echo('[]');
SELECT jt_echo('{}');
SELECT jt_echo('3.14');

-- A rewrite in native Ruby: redact keys anywhere, hashes all the way down.
CREATE FUNCTION jt_redact(doc jsonb, key text) RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    redact = lambda { |v|
        case v
        when Hash  then v.to_h { |k, e| [k, k == key ? '[redacted]' : redact.call(e)] }
        when Array then v.map { |e| redact.call(e) }
        else v
        end
    }
    redact.call(doc)
$$;
SELECT jt_redact('{"user": "jd", "password": "s3cret", "kids": [{"password": "also"}]}',
                 'password');

-- A NULL jsonb argument is nil; returning nil is SQL NULL.
CREATE FUNCTION jt_null(jsonb) RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    args[0]
$$;
SELECT jt_null(NULL) IS NULL AS null_roundtrip;

-- BigDecimal (any Numeric) serializes losslessly through its text form.
CREATE FUNCTION jt_bigdec() RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    require 'bigdecimal'
    {'exact' => BigDecimal('0.1') + BigDecimal('0.2')}
$$;
SELECT jt_bigdec();

-- Unsupported Ruby objects are rejected cleanly.
CREATE FUNCTION jt_bad() RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    {'when' => Time.at(0)}
$$;
SELECT jt_bad();

-- Non-finite floats cannot become jsonb numbers.
CREATE FUNCTION jt_inf() RETURNS jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    [Float::INFINITY]
$$;
SELECT jt_inf();

-- The transform reaches nested contexts: SETOF rows via return_next...
CREATE FUNCTION jt_batches(jsonb) RETURNS SETOF jsonb
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    args[0].each_slice(2) { |chunk| return_next({'chunk' => chunk}) }
    nil
$$;
SELECT * FROM jt_batches('[1, 2, 3, 4, 5]');

-- ...RETURNS TABLE with a jsonb column...
CREATE FUNCTION jt_stats(jsonb) RETURNS TABLE (key text, info jsonb)
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    args[0].each do |k, v|
        return_next({'key' => k, 'info' => {'value' => v, 'class' => v.class.name}})
    end
    nil
$$;
SELECT * FROM jt_stats('{"a": 1, "b": "two"}') ORDER BY key;

-- ...composite arguments and returns with jsonb fields...
CREATE TYPE jt_doc AS (id int, body jsonb);
CREATE FUNCTION jt_comp(jt_doc) RETURNS jt_doc
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    doc = args[0]
    {'id' => doc['id'] + 1,
     'body' => doc['body'].merge('seen' => doc['body'].keys.sort)}
$$;
SELECT id, body FROM jt_comp(ROW(1, '{"x": 1, "y": 2}')) AS t;

-- ...OUT parameters...
CREATE FUNCTION jt_split(doc jsonb, OUT small jsonb, OUT big jsonb)
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    small = doc.select { |_, v| v.is_a?(Numeric) && v < 10 }
    big   = doc.reject { |_, v| v.is_a?(Numeric) && v < 10 }
$$;
SELECT * FROM jt_split('{"a": 1, "b": 100, "c": 5}');

-- ...and jsonb[] arguments and returns, element by element.
CREATE FUNCTION jt_arr(jsonb[]) RETURNS jsonb[]
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    args[0].map { |doc| doc.merge('n' => doc.size) } + [{'extra' => true}]
$$;
SELECT jt_arr(ARRAY['{"a": 1}'::jsonb, '{"b": 2, "c": 3}'::jsonb]);

-- Triggers that declare the transform get $_TD jsonb columns as Ruby data,
-- and 'MODIFY' accepts Ruby data back.
CREATE TABLE jt_events (id int, payload jsonb);
CREATE FUNCTION jt_stamp() RETURNS trigger
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    doc = $_TD['new']['payload']
    elog('NOTICE', "payload is a #{doc.class.name} with #{doc.size} key(s)")
    $_TD['new']['payload'] = doc.merge('audited' => true, 'op' => $_TD['event'])
    'MODIFY'
$$;
CREATE TRIGGER jt_events_stamp BEFORE INSERT OR UPDATE ON jt_events
    FOR EACH ROW EXECUTE PROCEDURE jt_stamp();
INSERT INTO jt_events VALUES (1, '{"amount": 42}');
UPDATE jt_events SET payload = '{"amount": 99}' WHERE id = 1;
SELECT id, payload FROM jt_events;

-- A DELETE trigger reads the old row's jsonb as Ruby data too.
CREATE FUNCTION jt_obituary() RETURNS trigger
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    elog('NOTICE', "deleting amount=#{$_TD['old']['payload']['amount']}")
    nil
$$;
CREATE TRIGGER jt_events_bye BEFORE DELETE ON jt_events
    FOR EACH ROW EXECUTE PROCEDURE jt_obituary();
DELETE FROM jt_events;

-- Without the clause, a trigger sees the String form, as always.
CREATE FUNCTION jt_plain_trig() RETURNS trigger LANGUAGE plruby AS $$
    elog('NOTICE', "payload is a #{$_TD['new']['payload'].class.name}")
    nil
$$;
CREATE TRIGGER jt_events_plain BEFORE INSERT ON jt_events
    FOR EACH ROW EXECUTE PROCEDURE jt_plain_trig();
DROP TRIGGER jt_events_stamp ON jt_events;
INSERT INTO jt_events VALUES (2, '{"x": 1}');
DROP TABLE jt_events;

-- Without the TRANSFORM clause, jsonb still travels as a String.
CREATE FUNCTION jt_untransformed(jsonb) RETURNS text LANGUAGE plruby AS $$
    args[0].class.name
$$;
SELECT jt_untransformed('{"a": 1}');

-- SPI results are not affected by the function's transforms: a jsonb column
-- read through spi_exec is still its String form.
CREATE FUNCTION jt_spi() RETURNS text
TRANSFORM FOR TYPE jsonb
LANGUAGE plruby AS $$
    row = spi_fetch_row(spi_exec(%q{select '{"a": 1}'::jsonb as d}))
    row['d'].class.name
$$;
SELECT jt_spi();
