pgrecon 0.3.0
Triggers convert, concatenation keeps Oracle's NULL semantics, and the emitter is hardened by a nine-schema live sweep.
Triggers: a simple DML trigger becomes a trigger function plus the CREATE TRIGGER statement PostgreSQL wants. :NEW and :OLD lose their colons, INSERTING/UPDATING/DELETING become TG_OP tests, bare RETURN gains the row result, UPDATE OF column lists survive from the parse tree, WHEN clauses translate through the same folding as views - including the shape PostgreSQL refuses at CREATE, a WHEN reading NEW on a DELETE trigger, which moves into the body as a guard. Disabled triggers stay disabled. Compound, INSTEAD OF, system triggers, and UPDATING('column') refuse by name into the residue report.
Concatenation: every || chain becomes NULLIF(concat(...), ''). Oracle treats NULL as the empty string where PostgreSQL || yields NULL; concat() ignores NULLs, and NULLIF restores the one case Oracle does return NULL - every part empty. Inner rewrites compose inside the operands, and Oracle's CONCAT() folds the same way, in code, triggers, views, checks, defaults, and WHEN clauses.
Hardening from running the converter across nine schemas - Oracle's HR, OE, and customer_orders samples, utPLSQL, PLJSON, Logger, Alexandria, and two migration corpora - with every output applied to live PostgreSQL 16 under check_function_bodies: identity columns become integer identity columns and widen their foreign key references with them; SYSTIMESTAMP and GROUPING_ID fold to their PostgreSQL forms; SYS_OP_* internals refuse; I_SNAP$ snapshot indexes skip with a note; object views refuse by name; divisions fold to decimal so two-argument ROUND survives; constraints and indexes named after their table gain a suffix instead of colliding in PostgreSQL's relation namespace; database links ship as a commented oracle_fdw recipe. Across all nine schemas the converter emitted zero statements PostgreSQL rejected; everything it cannot prove is a named line in the residue report.