From 0b8bfa9255707a0b4037d6b2fc953a0038e8aa87 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 10 Nov 2026 00:17:13 +0000 Subject: [PATCH] Rewrite .to_s/.to_i/.to_f only as whole words The shared rewriter turned '.to_' plus i, s or f into a cast wherever it appeared, so a field named to_status became r::texttatus (and to_s_name r::text_name). The suffix must now end the word. 'x.to_s()' drops the empty argument list with the method name instead of leaving '::text()'. Reported by the plxcsharp front end's review. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ src/plx_transpile.c | 22 ++++++++++++++++------ test/expected/plx_features.out | 13 +++++++++++++ test/sql/plx_features.sql | 9 +++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..aae807c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to plx are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/), and plx uses the extension version in `plx.control` (currently `1.0`). +## [Unreleased] + +### Fixed + +- `.to_s`, `.to_i` and `.to_f` were rewritten as casts wherever the text + appeared, so a field whose name starts with one of them broke: + `r.to_status` became `r::texttatus`. They are now rewritten only as whole + words, and `x.to_s()` with an empty argument list works too. + ## [2.0.1] - 2026-08-25 Packaging only. **The extension is unchanged at 2.0.0**, so there is no diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 0a23018..3f9b6ac 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -1379,14 +1379,24 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) } continue; } - /* .to_i / .to_s / .to_f casts */ - if (c == '.' && i + 4 <= len && strncmp(s + i, ".to_", 4) == 0) + /* + * .to_i / .to_s / .to_f casts, as whole words only: a field such as + * r.to_status or r.to_s_name is left alone. An empty argument list, + * x.to_s(), is dropped with the method name. + */ + if (c == '.' && i + 5 <= len && strncmp(s + i, ".to_", 4) == 0 && + (s[i + 4] == 'i' || s[i + 4] == 's' || s[i + 4] == 'f') && + !(i + 5 < len && (isalnum((unsigned char) s[i + 5]) || s[i + 5] == '_' || + s[i + 5] == '$' || IS_HIGHBIT_SET(s[i + 5])))) { - char k = (i + 4 < len) ? s[i + 4] : '?'; + char k = s[i + 4]; - if (k == 'i') { appendStringInfoString(&out, "::integer"); i += 5; continue; } - if (k == 's') { appendStringInfoString(&out, "::text"); i += 5; continue; } - if (k == 'f') { appendStringInfoString(&out, "::double precision"); i += 5; continue; } + appendStringInfoString(&out, k == 'i' ? "::integer" : + k == 's' ? "::text" : "::double precision"); + i += 5; + if (i + 1 < len && s[i] == '(' && s[i + 1] == ')') + i += 2; + continue; } /* operator runs */ if (strchr("=!<>+-*/%&|?:~^", c)) diff --git a/test/expected/plx_features.out b/test/expected/plx_features.out index 859077e..585069f 100644 --- a/test/expected/plx_features.out +++ b/test/expected/plx_features.out @@ -233,3 +233,16 @@ SELECT f_strict() AS strict_no_row; -1 (1 row) +-- .to_s / .to_i / .to_f are rewritten as whole words only, so a field whose +-- name starts with to_s, to_i or to_f is left alone; x.to_s() also works +CREATE FUNCTION f_to_cast(n int) RETURNS text LANGUAGE plxruby AS $$ +r = fetch_one("select 'shipped' as to_status, 'x' as to_s_name, 2 as to_items") +return "#{r.to_status}/#{r.to_s_name}/#{r.to_items}/#{n.to_s}/#{n.to_s()}/#{'4.5'.to_f}/#{'12'.to_i + 1}" +$$; +SELECT f_to_cast(3); + f_to_cast +------------------------ + shipped/x/2/3/3/4.5/13 +(1 row) + +DROP FUNCTION f_to_cast(int); diff --git a/test/sql/plx_features.sql b/test/sql/plx_features.sql index 6e2699d..0850774 100644 --- a/test/sql/plx_features.sql +++ b/test/sql/plx_features.sql @@ -143,3 +143,12 @@ rescue => e end $$; SELECT f_strict() AS strict_no_row; + +-- .to_s / .to_i / .to_f are rewritten as whole words only, so a field whose +-- name starts with to_s, to_i or to_f is left alone; x.to_s() also works +CREATE FUNCTION f_to_cast(n int) RETURNS text LANGUAGE plxruby AS $$ +r = fetch_one("select 'shipped' as to_status, 'x' as to_s_name, 2 as to_items") +return "#{r.to_status}/#{r.to_s_name}/#{r.to_items}/#{n.to_s}/#{n.to_s()}/#{'4.5'.to_f}/#{'12'.to_i + 1}" +$$; +SELECT f_to_cast(3); +DROP FUNCTION f_to_cast(int);