Skip to content

Commit 82d1175

Browse files
authored
Merge pull request #4 from commandprompt/test/differential-check
Add a differential check, and fix the plxgo Sprintf bug it found
2 parents afe63de + b3affbf commit 82d1175

11 files changed

Lines changed: 1159 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
test/results/
88
regression.diffs
99
regression.out
10+
# Python bytecode from the test runners
11+
__pycache__/
12+
*.pyc
13+
1014
# editor
1115
*.swp
1216
*~

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ version in `plx.control` (currently `1.0`).
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- `plxgo`: `fmt.Sprintf` in expression position passed its Go format string
12+
straight into SQL `format()`, so any verb other than `%s` raised
13+
`unrecognized format() type specifier` when the function was called. The
14+
transpile succeeded and the failure only appeared at run time, which made
15+
`fmt.Sprintf("%d", n)`, the ordinary way to format an integer in Go, produce
16+
a function that could not run. Go's verbs are now rewritten to the `%s` that
17+
`format()` understands, keeping a `-` flag and a width and dropping the
18+
flags and precision `format()` has no equivalent for. Found by the new
19+
differential check.
20+
21+
### Added
22+
23+
- `make differentialcheck` (`test/differential.py`): each case is one program
24+
written as a plpgsql reference and once per dialect, called with the same
25+
arguments and required to agree with the reference on both values and error
26+
SQLSTATEs. Intended divergences are recorded per case with a reason and
27+
reported separately, and the check fails if a recorded divergence stops
28+
happening, so a documented limitation cannot outlive its documentation.
29+
930
### Changed
1031

1132
- Internal refactor of the transpiler behind a `PlxSurface.parse_body` vtable

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ EXTENSION = plx
66
DATA = plx--1.0.sql plx--1.1.sql plx--1.1.1.sql plx--1.2.sql plx--1.2.1.sql plx--1.2.2.sql plx--1.3.0.sql plx--1.3.1.sql plx--1.0--1.1.sql plx--1.1--1.1.1.sql plx--1.1.1--1.2.sql plx--1.2--1.2.1.sql plx--1.2.1--1.2.2.sql plx--1.2.2--1.3.0.sql plx--1.3.0--1.3.1.sql
77

88
# pg_regress suite (make installcheck). test/run_corpus.py is an additional
9-
# Ruby corpus runner.
9+
# Ruby corpus runner, and test/differential.py checks each dialect against a
10+
# plpgsql reference (make differentialcheck).
1011
REGRESS = plxruby plxphp plxjs plxpython3 plxcobol plxplsql plxts plxtsql plxgo plx_features plx_output plx_strbuild plx_errors
1112
REGRESS_OPTS = --inputdir=test --outputdir=test
1213

@@ -18,3 +19,12 @@ include $(PGXS)
1819
# PGXS does not track header dependencies; declare them so a header change
1920
# rebuilds every object (the shared enum/struct layout must stay consistent).
2021
$(OBJS): src/plx.h src/plx_int.h src/plx_engine.h
22+
23+
PLX_PYTHON ?= python3
24+
25+
# Differential check: run every dialect's version of a program against the
26+
# plpgsql the same program would have been written in, and require them to
27+
# agree. Needs the extension installed and a running server, like installcheck.
28+
.PHONY: differentialcheck
29+
differentialcheck:
30+
$(PLX_PYTHON) test/differential.py

doc/ARCHITECTURE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,39 @@ load a full interpreter into the backend. The transpiler is C that parses
158158
untrusted input at `CREATE FUNCTION` time; it has a recursion-depth limit and is
159159
fuzzed (see `test/fuzz.py`).
160160

161+
## Testing
162+
163+
Four layers, each answering a different question.
164+
165+
`make installcheck` runs the pg_regress suite in `test/sql`, one file per
166+
dialect plus the shared feature, output, string-builder and error files. This
167+
is the record of what each dialect accepts and what plpgsql it produces.
168+
169+
`test/run_corpus.py` replays a corpus of Ruby programs and compares their
170+
output to a recorded expectation, including the programs that are supposed to
171+
be rejected.
172+
173+
`test/fuzz.py` feeds mutated and pathological sources to every front end. It
174+
asserts nothing about the output; it asserts that the transpiler does not
175+
crash, hang, or exhaust memory on input written to break it.
176+
177+
`make differentialcheck` runs `test/differential.py`, which answers the
178+
question the other three cannot. Each case is one small program written once
179+
as a plpgsql reference and once per dialect, then called with the same
180+
arguments. The reference is the plpgsql a PostgreSQL developer would have
181+
written for the same logic, so it acts as an oracle: plx claims a dialect body
182+
transpiles to plpgsql that behaves the same way, and a disagreement is a defect
183+
rather than a difference of opinion. Values are compared by their text form,
184+
with NULL distinguished from the empty string, and a case that raises must
185+
raise the same SQLSTATE.
186+
187+
Some divergences are intended, and a case records them in a `documented` entry
188+
naming the dialects, the calls, and the reason. Those are reported separately
189+
rather than failing. The check also fails when a recorded divergence stops
190+
happening, so a limitation cannot quietly outlive the documentation that
191+
describes it. Where a case cannot cover a dialect, the run prints the gap
192+
instead of passing over it.
193+
161194
## Related documents
162195

163196
- [TRANSPILER.md](TRANSPILER.md): the original transpiler design specification.

doc/LIMITATIONS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ The per-dialect chapter is authoritative; this is a quick reference.
117117
- `+` for string concatenation (use `||` or build a slice and `array_to_string`).
118118
- Only a subset of `fmt`/`strings`/`math`/`strconv` is mapped; other calls pass
119119
through and must be valid PostgreSQL functions.
120+
- `fmt.Sprintf` renders every operand in its SQL text form, because each Go verb
121+
becomes `format()`'s `%s`. A `-` flag and a width are kept, so `%-8d` still
122+
pads. Go's other flags and its precision field are dropped, so `%.2f` prints
123+
the operand in full rather than rounding it. The verbs that change an
124+
operand's representation rather than its padding do not do so here: `%x`,
125+
`%o`, `%b`, `%e` and `%q` all produce the same text `%s` would, so
126+
`fmt.Sprintf("%x", 255)` yields `255` and not `ff`. Convert explicitly (for
127+
example `to_hex`) where the representation matters.
120128

121129
### plxcobol ([chapter](plxcobol.md))
122130

doc/plxgo.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,22 @@ to `a[(i) + 1]`. Indexing therefore follows Go semantics: `a[0]` is the first
136136
element, and `len(a)`, `for i := range a`, and `a[i]` all agree on 0-based
137137
positions. Go slice expressions (`a[i:j]`) are not translated.
138138

139-
`fmt.Println`/`fmt.Printf` raise a `NOTICE` with one `%` placeholder per value
140-
argument (space-separated); a `Printf`/`Sprintf` format string's literal text and
141-
directives are not reproduced, since SQL `RAISE` has no printf verbs.
139+
`fmt.Println`/`fmt.Printf` used as a statement raise a `NOTICE` with one `%`
140+
placeholder per value argument (space-separated); there the format string's
141+
literal text and directives are not reproduced, since SQL `RAISE` has no printf
142+
verbs.
143+
144+
`fmt.Sprintf` in an expression becomes SQL `format()`, and there the format
145+
string is reproduced. Every Go verb renders its operand as text, which is what
146+
`format()`'s `%s` does, so `%d`, `%v`, `%f`, `%q` and the rest all become `%s`.
147+
A `-` flag and a width are kept, so `%-8d` still pads to eight columns. Go's
148+
other flags and its precision field have no `format()` equivalent and are
149+
dropped, so `%.2f` prints the operand's full text rather than rounding it to
150+
two decimal places. Verbs that change an operand's representation rather than
151+
its padding are affected the same way: `%x`, `%o`, `%b`, `%e` and `%q` all
152+
render what `%s` would, so `fmt.Sprintf("%x", 255)` yields `255` and not `ff`.
153+
A doubled `%%` stays a literal `%`, and a `%` that starts no directive is
154+
passed through as a literal percent.
142155

143156
### Types (in declarations)
144157

src/plx_dialect_go.c

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,85 @@ go_emit_str(GoTok *tk, StringInfo out)
410410
appendStringInfoChar(out, '\'');
411411
}
412412

413+
/* emit a Go format string as one SQL format() accepts.
414+
415+
* Go's verbs all render their operand as text, which is what format()'s %s
416+
* does, so every directive becomes %s. format() takes only a '-' flag and a
417+
* width, so Go's other flags and its precision field are dropped: the operand
418+
* still appears, just without the padding Go would have applied. A doubled
419+
* A doubled %% passes through, and a '%' that starts no directive is escaped
420+
* to %%, so it reaches the caller as a literal percent instead of tripping
421+
* format() at run time.
422+
*/
423+
static void
424+
go_emit_format_str(GoTok *tk, StringInfo out)
425+
{
426+
StringInfoData raw;
427+
int i;
428+
429+
initStringInfo(&raw);
430+
go_emit_str(tk, &raw); /* a quoted SQL literal, escapes decoded */
431+
432+
for (i = 0; i < raw.len; i++)
433+
{
434+
char c = raw.data[i];
435+
bool dash = false;
436+
int j,
437+
wstart,
438+
wlen;
439+
440+
if (c != '%')
441+
{
442+
appendStringInfoChar(out, c);
443+
continue;
444+
}
445+
if (i + 1 < raw.len && raw.data[i + 1] == '%')
446+
{
447+
appendStringInfoString(out, "%%");
448+
i++;
449+
continue;
450+
}
451+
452+
j = i + 1;
453+
while (j < raw.len && (raw.data[j] == '-' || raw.data[j] == '+' ||
454+
raw.data[j] == ' ' || raw.data[j] == '#' ||
455+
raw.data[j] == '0'))
456+
{
457+
if (raw.data[j] == '-')
458+
dash = true;
459+
j++;
460+
}
461+
wstart = j;
462+
while (j < raw.len && raw.data[j] >= '0' && raw.data[j] <= '9')
463+
j++;
464+
wlen = j - wstart;
465+
if (j < raw.len && raw.data[j] == '.')
466+
{
467+
j++;
468+
while (j < raw.len && raw.data[j] >= '0' && raw.data[j] <= '9')
469+
j++;
470+
}
471+
if (j >= raw.len ||
472+
!((raw.data[j] >= 'a' && raw.data[j] <= 'z') ||
473+
(raw.data[j] >= 'A' && raw.data[j] <= 'Z')))
474+
{
475+
/* starts no directive: escape it, since a lone '%' is itself
476+
* an error to format() */
477+
appendStringInfoString(out, "%%");
478+
continue;
479+
}
480+
481+
appendStringInfoChar(out, '%');
482+
if (dash)
483+
appendStringInfoChar(out, '-');
484+
if (wlen > 0)
485+
appendBinaryStringInfo(out, raw.data + wstart, wlen);
486+
appendStringInfoChar(out, 's');
487+
i = j;
488+
}
489+
pfree(raw.data);
490+
}
491+
413492
/* map a Go base type name to a PostgreSQL type; NULL if unknown */
414493
static const char *
415494
go_base_type(const char *s, int len)
@@ -580,6 +659,22 @@ go_emit_call(Go *g, int i, int b, StringInfo out, int *ni)
580659
close = go_match(g, lp, b);
581660
if (close < 0)
582661
return false;
662+
663+
/* fmt.Sprintf in expression position: reproduce the format string,
664+
* rewriting Go's verbs into the ones SQL format() understands */
665+
if (go_ci(pkg, "fmt") && go_ci(meth, "Sprintf") &&
666+
close > lp + 1 && g->t[lp + 1].kind == GO_STR)
667+
{
668+
go_sp(out);
669+
appendStringInfoString(out, "format(");
670+
go_emit_format_str(&g->t[lp + 1], out);
671+
if (lp + 2 < close)
672+
go_emit_range(g, lp + 2, close, out);
673+
appendStringInfoChar(out, ')');
674+
*ni = close + 1;
675+
return true;
676+
}
677+
583678
for (k = 0; r[k].pkg; k++)
584679
if ((int) strlen(r[k].pkg) == pkg->len &&
585680
strncmp(r[k].pkg, pkg->s, pkg->len) == 0 &&

0 commit comments

Comments
 (0)