From 2eedc1d6575b63f40f4d2f226d9b9f3926b83a45 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 2 Nov 2026 13:25:22 -0500 Subject: [PATCH v1 2/2] Add property-based test harness and a curated set of properties pg_prop is a small C program that binds libhegel's stable C ABI (hegel.h): the engine generates many inputs, checks an invariant, and on failure shrinks to a minimal counterexample and prints a seed that replays it. A property is an ordinary C function that draws inputs and asserts a round-trip, an algebraic law, or a differential equality. A TAP driver runs each area against a throwaway server; a developer reruns one area or property by hand with --iterations / --seed. The initial set is deliberately small: six single-table areas whose oracles are round-trips and algebraic laws (base64, bytea, uuid, numeric, interval I/O, jsonb round-trip) plus an in-process PRNG self-test -- 37 properties that replay deterministically from a printed seed, which is the property a buildfarm test needs. Further areas, and a multi-table query generator with a differential oracle, are left for follow-ups. This method has already found live bugs on master; see the thread. --- src/test/prop/Makefile | 47 ++ src/test/prop/README | 307 ++++++++ src/test/prop/meson.build | 51 ++ src/test/prop/pg_prop.c | 1039 +++++++++++++++++++++++++++ src/test/prop/pg_prop.h | 381 ++++++++++ src/test/prop/pg_prop_sql.c | 409 +++++++++++ src/test/prop/prop_base64.c | 99 +++ src/test/prop/prop_bytea.c | 320 +++++++++ src/test/prop/prop_intervalio.c | 312 ++++++++ src/test/prop/prop_jsonbroundtrip.c | 373 ++++++++++ src/test/prop/prop_numeric.c | 326 +++++++++ src/test/prop/prop_prng.c | 132 ++++ src/test/prop/prop_uuid.c | 304 ++++++++ src/test/prop/t/001_prop.pl | 93 +++ 14 files changed, 4193 insertions(+) create mode 100644 src/test/prop/Makefile create mode 100644 src/test/prop/README create mode 100644 src/test/prop/meson.build create mode 100644 src/test/prop/pg_prop.c create mode 100644 src/test/prop/pg_prop.h create mode 100644 src/test/prop/pg_prop_sql.c create mode 100644 src/test/prop/prop_base64.c create mode 100644 src/test/prop/prop_bytea.c create mode 100644 src/test/prop/prop_intervalio.c create mode 100644 src/test/prop/prop_jsonbroundtrip.c create mode 100644 src/test/prop/prop_numeric.c create mode 100644 src/test/prop/prop_prng.c create mode 100644 src/test/prop/prop_uuid.c create mode 100644 src/test/prop/t/001_prop.pl diff --git a/src/test/prop/Makefile b/src/test/prop/Makefile new file mode 100644 index 00000000000..cd4df6c1f3d --- /dev/null +++ b/src/test/prop/Makefile @@ -0,0 +1,47 @@ +# src/test/prop/Makefile +# +# Property-based tests built on libhegel. Only reachable when configure was +# run with --with-hegel (which also requires --enable-tap-tests); see +# src/test/Makefile. +# +# Everything links into one program, pg_prop, whose first argument selects the +# area: pg_prop [options] [property ...]. The TAP driver runs each +# area; a developer debugging a failure runs one area or one property by hand +# with --iterations / --seed. See README. +# +# This is the initial curated set: single-table areas with sound, fully +# reproducible oracles. Add prop_.o here as each area lands (keep in +# step with the area list in t/001_prop.pl). + +PGFILEDESC = "pg_prop - property-based tests" +PGAPPICON = win32 + +PROGRAM = pg_prop +OBJS = \ + $(WIN32RES) \ + pg_prop.o \ + pg_prop_sql.o \ + prop_base64.o \ + prop_bytea.o \ + prop_uuid.o \ + prop_numeric.o \ + prop_intervalio.o \ + prop_jsonbroundtrip.o \ + prop_prng.o + +PG_CPPFLAGS = -I$(libpq_srcdir) +PG_LIBS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport) +PG_LIBS_INTERNAL += -lhegel_c + +NO_INSTALL = 1 +TAP_TESTS = 1 + +subdir = src/test/prop +top_builddir = ../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk + +LDFLAGS += $(foreach dir,$(LIBRARY_DIRS),-Wl,-rpath,$(dir)) + +pg_prop$(X): | submake-libpgfeutils +check: pg_prop$(X) diff --git a/src/test/prop/README b/src/test/prop/README new file mode 100644 index 00000000000..bab630c70e6 --- /dev/null +++ b/src/test/prop/README @@ -0,0 +1,307 @@ +src/test/prop/README + +Property-based tests +==================== + +This directory holds PostgreSQL's optional property-based tests (PBTs), built +on the Hegel engine (libhegel; see https://hegel.dev). A property-based test +states a property that must hold for every input in some domain -- a +round-trip, an invariant, an algebraic identity, or agreement between two ways +of computing the same answer -- and the engine generates many inputs trying to +falsify it. When it finds a counterexample it shrinks it to a minimal failing +input and prints a blob that replays it exactly. + +These tests are entirely optional and are off by default. They are built and +run only when all of the following hold: + +1. you explicitly enable them at configure time (--with-hegel for autoconf, + -Dprop_tests=enabled for meson); + +2. libhegel (its hegel.h header and libhegel_c shared library) is present and + reachable via --with-includes/--with-libraries (autoconf) or + --extra-include-dirs/--extra-lib-dirs (meson); and + +3. TAP tests are enabled (--enable-tap-tests / -Dtap_tests=enabled). + +If any piece is missing the suite is silently skipped. If the opt-in flag is +set but a piece is missing, configure fails with a clear message rather than +quietly doing nothing. + +See DESIGN in this directory for how it all works under the covers. + + +When to use a property-based test (and when not to) +=================================================== + +Reach for a PBT when the code has a property that should hold across a large +input space: + +* Round-trips: parse(format(x)) = x, decompress(compress(x)) = x. + +* Idempotence: f(f(x)) = f(x) -- normalization, case folding, canonical path + cleanup. + +* Invariants and contracts: a verifier stops on a character boundary; a + function's output stays within documented bounds; a comparator is a total + order. + +* Differential oracles: two ways of computing the same answer must agree -- an + index scan versus a sequential scan, a query with an optimization on versus + off, a partitioned table versus an unpartitioned one with the same rows. + +* No-crash and robustness: a parser handles arbitrary bytes without crashing. + +Prefer an ordinary regression or TAP test when: + +* The test checks exact output. "render(x) = ..." depends on a specific + format; there is no general property, just a fixed expectation. + +* The test checks a specific error message. PBTs are good at "an error is + raised", poor at "the error says exactly this". Test the error class or + SQLSTATE, never the wording (it is reworded and translated between releases). + +* The output is deliberately nondeterministic: random(), unseeded + gen_random_uuid(), clock_timestamp(), pg_stat_*, timing, memory figures. + There is no stable oracle. + +* The target is plan shape or cost, not plan results. EXPLAIN output is + cost-model dependent and is intended to change across releases and with + statistics. (Plan results are a prime PBT target -- see the query_* areas; + plan shape is not.) + +* The only oracle is a second copy of the same algorithm. If the test + reimplements the function to check the function, it finds bugs in the test, + not the server. This is the single most common way a property goes wrong; + prefer a round-trip or a differential oracle (two independent ways that must + agree) over an invented formula. + +* The target is floating-point textual edge output (extra_float_digits, + last-ULP rendering): that is platform and libc behavior, not PostgreSQL's. + +* The target is performance or resource use. PBT asserts correctness, not + speed; that is a benchmark. + +* Setup dominates. If each case needs elaborate fixtures, the generator is + mostly scaffolding and a few hand-written cases are clearer. + +* No real property is apparent. Don't force a tautology ("x = x"); a good + unit test beats a contrived PBT. + +The rule of thumb: a PBT belongs only where a disagreement is unambiguously a +bug. That is why the SQL-oracle (Tier B) areas lean on differential oracles +and round-trips, not invented formulas. + +A PBT complements, rather than replaces, the existing regression and TAP +suites. Its value is exercising the combinations and boundary inputs a human +does not think to write by hand -- the three-byte "0.0" fed one byte at a time, +the two-partition prefix-equality query -- not raising line coverage. Measured, +the suite adds only about 0.4% line coverage over the regression and isolation +suites, and it does not execute shared lines more often than they do; its +measurable gain is reaching different lines (for example, 242 lines of +src/common/jsonapi.c that the json and jsonb regression tests never reach) plus +the standing differential net over the planner and executor. See DESIGN for +the measurement method and figures. + + +Running +======= + +The tests are organized into areas, each targeting one piece of code. There +are two kinds. In-process (Tier A) areas link the code under test directly +into the test program and need no server. SQL-oracle (Tier B) areas drive SQL +against a running cluster and compare the answer to an oracle. All areas are +compiled into one program, pg_prop, whose first argument selects the area: + + pg_prop [options] [property ...] + +Through the test harness: + + # meson: run the whole prop suite (both TAP drivers) + meson test --suite prop + + # meson: one driver (001 = in-process areas, 002 = SQL-oracle areas) + meson test prop/001_prop + meson test prop/002_sql_props + + # autoconf + make -C src/test/prop check + +Directly, for development and debugging. An in-process area needs no server: + + pg_prop --list # list every area/property + pg_prop jsonapi --list # any area filters the list + pg_prop jsonapi # all jsonapi properties + pg_prop jsonapi chunking_agrees_with_oneshot # one property + pg_prop jsonapi chunking_agrees_with_oneshot -n 100000 -s 42 + +A SQL-oracle area reads PGHOST/PGPORT/PGDATABASE from the environment, so point +it at any cluster: + + PGHOST=/tmp PGPORT=5432 PGDATABASE=postgres \ + pg_prop partprune pruning_preserves_rows -n 100000 -s 42 + +Controlling iterations and the seed: + +* -n, --iterations N -- number of test cases per property (default 100, kept + low so check-world stays cheap; raise it when hunting a bug). + +* -s, --seed N -- fix the RNG seed for a reproducible run. When you do not give + one, pg_prop picks a seed and prints it ("# seed: NNN"), so a failure seen in + CI can be replayed with -s NNN. + +The same three controls are available as the environment variables +PG_PROP_ITERATIONS, PG_PROP_SEED, and PG_PROP_TESTS (a space-separated list of +property names), which the TAP drivers forward -- so you can raise the +iteration count of a meson test run without editing anything: + + PG_PROP_ITERATIONS=10000 PG_PROP_SEED=42 meson test --suite prop + PG_PROP_TESTS="pruning_preserves_rows" meson test prop/002_sql_props + +Two further knobs tune the deep multi-table generator: + +* PG_PROP_GEN_PRNG -- control deep-generator reproducibility from a seed. + The multi-table builder makes hundreds of data-dependent draws per case, + a sequence the engine did not replay reliably (so a deep counterexample + could not be reproduced). When active, the builder takes one engine draw + per case and drives all structural choices from a deterministic PRNG seeded + by it, so the case replays exactly; the cost is coarser shrinking of deep + structure. DEFAULT: on whenever a deep profile is selected (PG_PROP_DEEP + set), because for a hunt/vet run replaying a hit matters more than the + engine's structural shrinking (which it cannot do for a deep query anyway); + off for the default single-table profile, where engine shrinking of the + short draw sequence works well. PG_PROP_GEN_PRNG=1 forces it on, + PG_PROP_GEN_PRNG=0 forces it off, overriding the profile default. + +* PG_PROP_GEN_BIAS=tag,tag,... -- bias the deep generator toward the SQL + surface a patch under review touches, when vetting a commitfest entry. + scripts/cf-features.py scans the patch diff and prints the tag list; feed + it in so the generator weights joins / setops / subquery / nulls / ... as + the patch does. Biasing only reweights -- every shape is still reachable + -- so a biased run focuses without losing neighbouring-bug coverage. + + PG_PROP_GEN_BIAS=$(scripts/cf-features.py /tmp/cfpatch/6824/*.patch) \ + PG_PROP_GEN_PRNG=1 PG_PROP_DEEP=fast \ + pg_prop query_diff -n 500 diff_deep_join + + +Active and frozen areas +======================= + +The SQL-oracle driver runs the active areas by default: the query generator +and its oracles (query_*), the stateful, concurrency and access-method areas, +and the commitfest areas (cf_*). The per-type scalar areas (numeric, text, +jsonb, ...) are frozen: they compile and run with PG_PROP_FROZEN=1, but are +not maintained. In the 2026 evaluation they found no server bug and most of +their findings were unsound oracles. A frozen area that breaks after an +upstream change may be deleted rather than fixed. + +New work goes into the generator (sql/query_gen.c) rather than new oracles. +A new query shape is checked by every existing oracle at once; a new oracle +checks only the shapes the generator already makes. + +The single-table generator (qg_build_table) and the oracles over it are +reproducible: a counterexample replays from its seed. The deep multi-table +generator (qg_build_db / qg_build_query, used by the query_deep, query_multi +and diff_deep_* properties under PG_PROP_DEEP) was once seen to be +non-reproducible for a fixed seed on one environment (finding 041), but that +no longer reproduces on the current libhegel in any profile, same-seed or +across-seed. For determinism that does not depend on the engine's +draw-replay behaviour at all, set PG_PROP_GEN_PRNG=1: the deep case is then +reproducible by construction (one engine draw drives a local PRNG). A deep +disagreement is still best reproduced by hand before it is believed, but a +deep run is no longer disqualified as a regression gate on that ground. + + +Commitfest properties and retirement +==================================== + +A cf_* property encodes one open commitfest entry: it fails on master and +passes with that entry's patch. It earns its keep while the patch is under +review. Once the fix is committed, the patch's own regression test covers +the case, and the property is retired: delete it (or move its invariant into +a generator shape, if it generalises). scripts/cf-retire.sh lists the +properties whose CF entry is committed, withdrawn or rejected. + + +Debugging a failure +=================== + +1. Read the counterexample. On failure pg_prop prints a one-line "falsified:" + message that includes the minimal inputs Hegel shrank to, plus a "rerun + with" blob. The TAP drivers echo this as "#" diagnostics. + +2. Reproduce it deterministically. Re-run the single property with the seed + from the run's "# seed:" line: + + pg_prop -s -n + + (For a SQL area, set PGHOST/PGPORT first.) You can also raise verbosity with + -v to see every drawn value, not just the final counterexample. + +3. Decide what the failure means. There are three possibilities: + + * A real bug in the code under test -- the code violated its own contract. + Capture the reproducer. + + * An unsound property -- the test asserted something the code never promised + (for example, that a string verifier and a character verifier agree on a + NUL byte -- they deliberately do not). Fix the property. + + * An over-broad generator -- the failing input is genuinely outside the + function's domain. Only then narrow the generator, and say why in a + comment. + +4. Step through it. The reproducing command is an ordinary process; run it + under gdb or lldb with a breakpoint in the code under test. Because the seed + is fixed, the same inputs are generated every time. + + +Adding a property +================= + +To add a property to an existing area, add a PG_PROP(name) { ... } function to +that area's prop_.c. It is registered automatically. + +To add a new area, create prop_.c beginning with + + #define PG_PROP_AREA "" + +before including pg_prop.h, then add the file to the source list in meson.build, +the OBJS list in Makefile, and the area list in the matching TAP driver +(t/001_prop.pl for in-process areas, t/002_sql_props.pl for SQL-oracle areas). + +See pg_prop.h for the full generator and assertion API, and DESIGN for the +rationale and internals. + + +Extensions +========== + +An extension can take part in the suite in two ways, and may do either or both. + +(a) Add its own area. This is just "add a new area" above with the extension's +name: a prop_.c that defines PG_PROP_AREA and uses PG_PROP(), whose connect +helper runs CREATE EXTENSION IF NOT EXISTS . Every contrib area already in +this tree (hstore, ltree, citext, cube, seg, intarray, pg_trgm, ...) is exactly +this shape. An out-of-tree extension ships the file and arranges for it to be +compiled into the pg_prop binary. + +(b) Augment an existing area through an extension point. A core area may +publish a named point and call pg_prop_run_hooks() there; an extension registers +a contributor with PG_PROP_HOOK("point", fn). The SQL query generator +(sql/query_gen.h) publishes three: + + query_gen.setup run DDL (CREATE EXTENSION) before each generated table; + ctx is the live connection. + query_gen.column add a typed column to the generated table; ctx is the + qg_schema being assembled. + query_gen.predicate emit a predicate leaf over the extension's column; ctx + is a qg_pred_ctx (schema + buffer). + +Registering those three teaches the generator about the extension's type, after +which every query-level oracle -- TLP, NoREC, cardinality, PQS, plan-advice -- +generates tables and queries that use the type and checks all of its invariants +over it, with no new oracle code. sql/prop_query_ext_citext.c is a complete +worked example (it also adds one citext-specific area, to show both styles in +one file). A core area makes itself extensible simply by publishing a point and +documenting the ctx type its contributors receive. diff --git a/src/test/prop/meson.build b/src/test/prop/meson.build new file mode 100644 index 00000000000..e5773a7f18c --- /dev/null +++ b/src/test/prop/meson.build @@ -0,0 +1,51 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Property-based test harness, built only when libhegel is available +# (-Dprop_tests=enabled; see the top-level meson.build). All areas link into +# one binary, pg_prop, whose first argument selects the area: +# pg_prop [options] [property ...]. +# t/001_prop.pl runs each area against a throwaway server it starts. +# +# This is the initial curated set: a handful of single-table areas with sound, +# fully reproducible oracles (round-trips and algebraic laws). Further areas +# are added here as they land. + +if not prop_tests_enabled + subdir_done() +endif + +prop_area_sources = files( + 'prop_base64.c', + 'prop_bytea.c', + 'prop_uuid.c', + 'prop_numeric.c', + 'prop_intervalio.c', + 'prop_jsonbroundtrip.c', + 'prop_prng.c', +) + +pg_prop = executable('pg_prop', + files('pg_prop.c', 'pg_prop_sql.c') + prop_area_sources, + include_directories: include_directories('.'), + dependencies: [frontend_code, libpq, libhegel], + kwargs: default_bin_args + { + 'install': false, + }, +) +testprep_targets += pg_prop + +# Register the Perl TAP driver only when TAP is available. Without TAP the +# pg_prop binary still builds and can be run directly. +if prop_tests_tap + tests += { + 'name': 'prop', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_prop.pl', + ], + 'deps': [pg_prop], + }, + } +endif diff --git a/src/test/prop/pg_prop.c b/src/test/prop/pg_prop.c new file mode 100644 index 00000000000..3546bb8bedd --- /dev/null +++ b/src/test/prop/pg_prop.c @@ -0,0 +1,1039 @@ +/*------------------------------------------------------------------------- + * + * pg_prop.c + * Property-based testing harness: libhegel driver, generators, CLI, TAP. + * + * See pg_prop.h for the API contract. This file is linked into every test + * binary under src/test/prop and provides main(). + * + * The CLI shape is: + * + * pg_prop_ [options] [PROPERTY ...] + * + * --list list registered properties and exit + * -n, --iterations N number of test cases per property (default 100) + * -s, --seed N fixed RNG seed (default: random, printed as a note) + * -v, --verbose raise engine verbosity + * -q, --quiet lower engine verbosity + * --no-tap plain output instead of TAP (for interactive use) + * -h, --help usage + * + * With no PROPERTY arguments every registered property runs. Environment + * variables PG_PROP_ITERATIONS, PG_PROP_SEED and PG_PROP_TESTS supply the same + * three controls (so they can be driven through the TAP wrapper and meson); + * explicit command-line options win over the environment. + * + * Output is TAP by default so meson/prove consume it directly: one "ok"/"not + * ok" line per property, the counterexample and replay blob as "#" diagnostic + * lines under a failure. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/pg_prop.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "common/logging.h" +#include "getopt_long.h" +#include "lib/stringinfo.h" +#include "pg_prop.h" + +/* + * libhegel's C ABI. Installed under a prefix given to configure via + * --with-includes / extra_include_dirs; the header has no PostgreSQL + * dependencies. + */ +#include "hegel.h" + +/* ---- registry -------------------------------------------------------- */ + +typedef struct PropEntry +{ + const char *area; + const char *name; + pg_prop_fn fn; + void *arg; +} PropEntry; + +#define MAX_PROPS 4096 +static PropEntry props[MAX_PROPS]; +static int nprops = 0; + +void +pg_prop_register(const char *area, const char *name, pg_prop_fn fn, void *arg) +{ + if (nprops >= MAX_PROPS) + { + fprintf(stderr, "pg_prop: too many properties (max %d)\n", MAX_PROPS); + exit(2); + } + props[nprops].area = area; + props[nprops].name = name; + props[nprops].fn = fn; + props[nprops].arg = arg; + nprops++; +} + +/* ---- extension-point (hook) registry --------------------------------- * + * + * An extension that wants to augment an existing area -- rather than add a + * whole new area of its own -- registers a hook against a named extension + * point that the area publishes. The area calls pg_prop_run_hooks() at the + * point, and every registered contributor runs in registration order with the + * area's context pointer. This is how, for example, a contrib type can teach + * the SQL query generator to emit predicates over its own columns, so the + * existing TLP/NoREC/cardinality/PQS oracles then cover that type for free. + * + * Hooks are a flat list keyed by point name; areas and extensions agree on + * the point name and the concrete type behind the void *ctx out of band (in + * the point's documentation), exactly as a C callback contract always does. + */ +typedef struct HookEntry +{ + const char *point; + pg_prop_hook_fn fn; +} HookEntry; + +#define MAX_HOOKS 1024 +static HookEntry hooks[MAX_HOOKS]; +static int nhooks = 0; + +void +pg_prop_register_hook(const char *point, pg_prop_hook_fn fn) +{ + if (nhooks >= MAX_HOOKS) + { + fprintf(stderr, "pg_prop: too many hooks (max %d)\n", MAX_HOOKS); + exit(2); + } + hooks[nhooks].point = point; + hooks[nhooks].fn = fn; + nhooks++; +} + +int +pg_prop_hooks_count(const char *point) +{ + int n = 0; + + for (int i = 0; i < nhooks; i++) + if (strcmp(hooks[i].point, point) == 0) + n++; + return n; +} + +void +pg_prop_run_hooks(pg_prop_tc *tc, const char *point, void *ctx) +{ + for (int i = 0; i < nhooks; i++) + if (strcmp(hooks[i].point, point) == 0) + hooks[i].fn(tc, point, ctx); +} + +/* + * Run the Nth hook registered against "point" (0-based over just that point's + * contributors), or do nothing if n is out of range. Lets an area pick one + * contributor at random -- e.g. the query generator choosing one extension + * type for a column -- rather than running all of them. + */ +void +pg_prop_run_hook_n(pg_prop_tc *tc, const char *point, int n, void *ctx) +{ + int seen = 0; + + for (int i = 0; i < nhooks; i++) + if (strcmp(hooks[i].point, point) == 0) + { + if (seen == n) + { + hooks[i].fn(tc, point, ctx); + return; + } + seen++; + } +} + +/* ---- per-case handle ------------------------------------------------- */ + +/* + * One pg_prop_tc wraps the libhegel context and the current test case. A + * failure is reported by longjmp back to the driver, which marks the case + * INTERESTING; the engine then shrinks and replays. We use setjmp rather + * than returning a status so pg_prop_fail can be called from arbitrarily deep + * in a property body (and so it can be pg_attribute_noreturn, which keeps the + * compiler from warning about missing returns after it). + */ +struct pg_prop_tc +{ + hegel_context_t *ctx; + hegel_test_case_t *tc; + StringInfoData failmsg; /* set when the body called pg_prop_fail */ + bool failed; + sigjmp_buf jmp; /* longjmp target: body -> driver */ + const char *origin; /* stable failure origin for this property */ +}; + +/* ---- engine error helper --------------------------------------------- */ + +static void +check_rc(hegel_context_t *ctx, hegel_result_t rc, const char *what) +{ + /* + * HEGEL_E_STOP_TEST and HEGEL_E_ASSUME are not errors: they are the + * engine telling the body to stop (budget exhausted) or that the case was + * assumed away. Both are handled by the callers that can see them; any + * other negative code is a real engine fault and is fatal. + */ + if (rc == HEGEL_OK || rc == HEGEL_E_STOP_TEST || rc == HEGEL_E_ASSUME) + return; + fprintf(stderr, "pg_prop: libhegel %s failed (%d): %s\n", + what, (int) rc, hegel_context_last_error(ctx)); + exit(2); +} + +/* + * A draw that returns HEGEL_E_STOP_TEST means the engine wants the body to + * stop immediately (it ran out of choice budget for this case). We model + * that as a failure-free longjmp out of the body: the driver then marks the + * case OVERRUN. pg_prop_fail reuses the same jump with failed=true. + */ +static pg_prop_tc *current_tc; /* for the stop/overrun jump */ +static bool overrun; + +/* + * Set when the body called pg_prop_skip(): the property is abandoned and + * reported skipped. Module-global (not per-case) because the whole property + * is skipped, not one case; run_property reads and clears it. + */ +static bool skip_requested; + +/* + * Vacuous-pass guard (see pg_prop_nonvacuous). opted_in becomes true the + * first time a property calls pg_prop_nonvacuous in a run; nonvacuous_cases + * counts the cases that reached their invariant check. A run that opted in + * but ran zero non-vacuous cases is reported as a failure, not a silent pass. + * Both are reset at the start of each property's run. + */ +static bool nonvacuous_opted_in; +static uint64 nonvacuous_cases; + +/* "area/property" now running, for diagnostics (see pg_prop_sql.c). */ +const char *pg_prop_current_area = NULL; +static char current_area_buf[256]; +static StringInfoData skip_reason; +static bool skip_reason_inited; + +static void +stop_test(pg_prop_tc *tc) +{ + overrun = true; + siglongjmp(tc->jmp, 1); +} + +/* ---- generators ------------------------------------------------------ */ + +int64 +pg_prop_draw_int(pg_prop_tc *tc, int64 min, int64 max) +{ + int64 out = 0; + hegel_result_t rc = hegel_generate_integer(tc->ctx, tc->tc, min, max, &out); + + if (rc == HEGEL_E_STOP_TEST) + stop_test(tc); + check_rc(tc->ctx, rc, "generate_integer"); + return out; +} + +double +pg_prop_draw_float(pg_prop_tc *tc, double min, double max) +{ + double out = 0; + hegel_result_t rc; + + /* + * Full doubles including NaN and the infinities only when the range is + * unbounded on the matching side, matching the engine's own defaults. + */ + rc = hegel_generate_float(tc->ctx, tc->tc, 64, min, max, + /* allow_nan */ isinf(min) || isinf(max) ? false : false, + /* allow_infinity */ false, + false, false, 5e-324, &out); + if (rc == HEGEL_E_STOP_TEST) + stop_test(tc); + check_rc(tc->ctx, rc, "generate_float"); + return out; +} + +bool +pg_prop_draw_bool(pg_prop_tc *tc) +{ + bool out = false; + hegel_result_t rc = hegel_generate_boolean(tc->ctx, tc->tc, 0.5, + false, false, &out); + + if (rc == HEGEL_E_STOP_TEST) + stop_test(tc); + check_rc(tc->ctx, rc, "generate_boolean"); + return out; +} + +unsigned char * +pg_prop_draw_bytes(pg_prop_tc *tc, size_t min_size, size_t max_size, size_t *len) +{ + hegel_generate_bytes_result_t res = {NULL, 0}; + hegel_result_t rc = hegel_generate_bytes(tc->ctx, tc->tc, min_size, + max_size, &res); + unsigned char *out; + + if (rc == HEGEL_E_STOP_TEST) + stop_test(tc); + check_rc(tc->ctx, rc, "generate_bytes"); + + out = pg_malloc(res.len + 1); + if (res.len > 0) + memcpy(out, res.data, res.len); + out[res.len] = '\0'; + *len = res.len; + hegel_generate_bytes_result_free(tc->ctx, &res); + return out; +} + +/* + * Shared text-draw path: build a one-shot string generator, draw from it, + * copy the result out, free both. Building a generator per draw is cheap + * relative to a round trip through the engine and keeps the API stateless. + */ +static char * +draw_string(pg_prop_tc *tc, hegel_string_generator_t *gen, size_t *len) +{ + hegel_generate_string_result_t res = {NULL, 0}; + hegel_result_t rc = hegel_generate_string(tc->ctx, tc->tc, gen, &res); + char *out; + + if (rc == HEGEL_E_STOP_TEST) + { + hegel_string_generator_free(tc->ctx, gen); + stop_test(tc); + } + if (rc == HEGEL_E_ASSUME) + { + /* generator rejected its own draw (e.g. length cap); discard case */ + hegel_string_generator_free(tc->ctx, gen); + siglongjmp(tc->jmp, 2); /* 2 == assume/invalid */ + } + check_rc(tc->ctx, rc, "generate_string"); + + out = pg_malloc(res.len + 1); + if (res.len > 0) + memcpy(out, res.data, res.len); + out[res.len] = '\0'; + *len = res.len; + hegel_generate_string_result_free(tc->ctx, &res); + hegel_string_generator_free(tc->ctx, gen); + return out; +} + +char * +pg_prop_draw_utf8(pg_prop_tc *tc, size_t min_size, size_t max_size, size_t *len) +{ + hegel_string_generator_t *gen = NULL; + hegel_result_t rc; + + rc = hegel_string_generator_text(tc->ctx, min_size, max_size, + "utf-8", 0, UINT32_MAX, + NULL, 0, NULL, 0, NULL, 0, NULL, 0, &gen); + check_rc(tc->ctx, rc, "string_generator_text"); + return draw_string(tc, gen, len); +} + +char * +pg_prop_draw_from(pg_prop_tc *tc, const char *alphabet, + size_t min_size, size_t max_size, size_t *len) +{ + hegel_string_generator_t *gen = NULL; + hegel_result_t rc; + + /* + * There is no "alphabet" field in the C ABI's text generator; emulate it + * with include_characters plus an empty category set, which restricts the + * draw to exactly those characters. + */ + rc = hegel_string_generator_text(tc->ctx, min_size, max_size, + "ascii", 0, UINT32_MAX, + /* categories: non-NULL empty list -> empty base alphabet */ + (const char *const *) &alphabet, 0, + NULL, 0, + (const unsigned char *) alphabet, strlen(alphabet), + NULL, 0, &gen); + check_rc(tc->ctx, rc, "string_generator_text(alphabet)"); + return draw_string(tc, gen, len); +} + +/* ---- assume / note / fail -------------------------------------------- */ + +void +pg_prop_assume(pg_prop_tc *tc, bool condition) +{ + if (!condition) + siglongjmp(tc->jmp, 2); /* 2 == assume/invalid */ +} + +void +pg_prop_note(pg_prop_tc *tc, const char *fmt,...) +{ + va_list ap; + StringInfoData s; + + initStringInfo(&s); + for (;;) + { + int needed; + + va_start(ap, fmt); + needed = appendStringInfoVA(&s, fmt, ap); + va_end(ap); + if (needed == 0) + break; + enlargeStringInfo(&s, needed); + } + hegel_note(tc->ctx, tc->tc, (const unsigned char *) s.data, s.len); + pfree(s.data); +} + +void +pg_prop_nonvacuous(pg_prop_tc *tc) +{ + (void) tc; + nonvacuous_opted_in = true; + nonvacuous_cases++; +} + +void +pg_prop_opt_into_vacuous_guard(pg_prop_tc *tc) +{ + (void) tc; + nonvacuous_opted_in = true; +} + +void +pg_prop_fail(pg_prop_tc *tc, const char *fmt,...) +{ + va_list ap; + + resetStringInfo(&tc->failmsg); + for (;;) + { + int needed; + + va_start(ap, fmt); + needed = appendStringInfoVA(&tc->failmsg, fmt, ap); + va_end(ap); + if (needed == 0) + break; + enlargeStringInfo(&tc->failmsg, needed); + } + tc->failed = true; + siglongjmp(tc->jmp, 1); /* 1 == interesting/failure */ +} + +void +pg_prop_skip(pg_prop_tc *tc, const char *fmt,...) +{ + va_list ap; + + if (!skip_reason_inited) + { + initStringInfo(&skip_reason); + skip_reason_inited = true; + } + resetStringInfo(&skip_reason); + for (;;) + { + int needed; + + va_start(ap, fmt); + needed = appendStringInfoVA(&skip_reason, fmt, ap); + va_end(ap); + if (needed == 0) + break; + enlargeStringInfo(&skip_reason, needed); + } + skip_requested = true; + siglongjmp(tc->jmp, 3); /* 3 == skip the whole property */ +} + +/* ---- fork isolation -------------------------------------------------- */ + +int +pg_prop_run_isolated(pg_prop_isolated_fn body, void *arg, char **detail) +{ + pid_t pid; + int status; + + if (detail) + *detail = NULL; + + fflush(stdout); + fflush(stderr); + pid = fork(); + if (pid < 0) + { + if (detail) + *detail = pg_strdup("fork failed"); + return -1; + } + if (pid == 0) + { + /* child: run the code under test, then _exit so no atexit/flush runs */ + body(arg); + fflush(stdout); + fflush(stderr); + _exit(0); + } + + /* parent: wait and classify */ + while (waitpid(pid, &status, 0) < 0) + { + if (errno != EINTR) + { + if (detail) + *detail = pg_strdup("waitpid failed"); + return -1; + } + } + + if (WIFEXITED(status)) + { + int code = WEXITSTATUS(status); + + if (code == 0) + return 0; + if (detail) + { + char buf[64]; + + snprintf(buf, sizeof(buf), "child exited with status %d", code); + *detail = pg_strdup(buf); + } + return code ? code : 1; + } + if (WIFSIGNALED(status)) + { + int sig = WTERMSIG(status); + + if (detail) + { + char buf[96]; + + snprintf(buf, sizeof(buf), "child killed by signal %d (%s)", + sig, strsignal(sig)); + *detail = pg_strdup(buf); + } + /* encode as a distinct non-zero code */ + return 1000 + sig; + } + if (detail) + *detail = pg_strdup("child stopped or unknown status"); + return -1; +} + +/* ---- driver ---------------------------------------------------------- */ + +/* + * Per-property run state passed from run_property() through the body-runner. + * libhegel's run loop hands us a test case; we build a pg_prop_tc, run the + * body under setjmp, and mark the case. + */ +typedef struct RunState +{ + PropEntry *prop; + StringInfoData last_fail; /* message from the last failing body */ +} RunState; + +static hegel_verbosity_t verbosity = HEGEL_VERBOSITY_NORMAL; + +/* + * Run the body for one test case handle; returns the hegel_status_t to pass + * to hegel_mark_complete. Captures the failure message into rs->last_fail. + */ +static uint32_t +run_one_case(RunState *rs, hegel_context_t *ctx, hegel_test_case_t *handle) +{ + pg_prop_tc t; + int jmpval; + uint32_t status; + + t.ctx = ctx; + t.tc = handle; + t.failed = false; + t.origin = rs->prop->name; + initStringInfo(&t.failmsg); + current_tc = &t; + overrun = false; + + jmpval = sigsetjmp(t.jmp, 1); + if (jmpval == 0) + { + rs->prop->fn(&t, rs->prop->arg); + /* body returned normally: property held for this case */ + status = HEGEL_STATUS_VALID; + } + else if (jmpval == 2) + { + /* assume/invalid */ + status = HEGEL_STATUS_INVALID; + } + else if (overrun) + { + status = HEGEL_STATUS_OVERRUN; + } + else if (jmpval == 3) + { + /* skip: the whole property is abandoned; treat this case as valid so + * the engine does not try to shrink it. skip_requested is read by + * run_property, which reports the property as skipped. */ + status = HEGEL_STATUS_VALID; + } + else + { + /* failure: keep the message for the report */ + resetStringInfo(&rs->last_fail); + appendBinaryStringInfo(&rs->last_fail, t.failmsg.data, t.failmsg.len); + status = HEGEL_STATUS_INTERESTING; + } + + pfree(t.failmsg.data); + + /* + * Close any SQL connection the body opened and did not close -- a body + * that failed or assumed away longjmp'd over its own pg_prop_disconnect(). + * This must run on every exit path, or a long run exhausts the server's + * connection slots. A no-op for in-process (Tier A) areas. + */ + pg_prop_sql_cleanup(); + + return status; +} + +/* + * Replay a counterexample blob to re-derive the failing message and drawn + * values for the report. Returns the (malloc'd) failure message. + */ +static char * +replay_blob(RunState *rs, hegel_context_t *ctx, const hegel_settings_t *s, + const char *blob) +{ + hegel_test_case_t *handle = NULL; + hegel_result_t rc; + uint32_t status; + char *msg; + + rc = hegel_test_case_from_blob(ctx, s, blob, NULL, NULL, &handle); + if (rc != HEGEL_OK || handle == NULL) + return pg_strdup("(could not replay counterexample)"); + + status = run_one_case(rs, ctx, handle); + hegel_mark_complete(ctx, handle, status, + status == HEGEL_STATUS_INTERESTING ? rs->prop->name : NULL); + hegel_test_case_free(ctx, handle); + + if (status == HEGEL_STATUS_INTERESTING && rs->last_fail.len > 0) + msg = pg_strdup(rs->last_fail.data); + else + msg = pg_strdup("(counterexample did not reproduce)"); + return msg; +} + +/* + * Run one property to completion. Returns true if it held, false if a + * counterexample was found. On failure, prints the message, the minimal + * counterexample's drawn values (via replay), and the replay blob as TAP + * diagnostics. "testno" is the 1-based TAP point number. + */ +static bool +run_property(PropEntry *prop, uint64 iterations, bool have_seed, uint64 seed, + const char *dbpath, int testno, bool tap) +{ + hegel_context_t *ctx = hegel_context_new(); + hegel_settings_t *s = NULL; + hegel_run_t *run = NULL; + hegel_run_result_t *result = NULL; + hegel_run_status_t rstatus; + RunState rs; + bool ok; + bool vacuous_fail = false; + + char dbkey[256]; + + rs.prop = prop; + initStringInfo(&rs.last_fail); + + check_rc(ctx, hegel_settings_new(ctx, &s), "settings_new"); + check_rc(ctx, hegel_settings_set_test_cases(ctx, s, iterations), "set_test_cases"); + check_rc(ctx, hegel_settings_set_verbosity(ctx, s, verbosity), "set_verbosity"); + check_rc(ctx, hegel_settings_set_seed(ctx, s, seed, have_seed), "set_seed"); + + /* + * Under a deep hunt (PG_PROP_DEEP set), a test case's "generation" is + * dominated by server-side DDL/DML building the database, which on a + * loaded machine routinely exceeds the engine's 30-second TooSlow + * threshold -- the check then measures server load, not the engine, and + * aborts the run. Suppress just that check in hunt mode; the committed + * TAP run (PG_PROP_DEEP unset) keeps every health check. + */ + if (getenv("PG_PROP_DEEP") != NULL && getenv("PG_PROP_DEEP")[0] != '\0' && + strcmp(getenv("PG_PROP_DEEP"), "0") != 0) + check_rc(ctx, hegel_settings_set_suppress_health_check(ctx, s, HEGEL_HC_TOO_SLOW), + "suppress_health_check"); + snprintf(dbkey, sizeof(dbkey), "%s/%s", prop->area, prop->name); + snprintf(current_area_buf, sizeof(current_area_buf), "%s", dbkey); + pg_prop_current_area = current_area_buf; + check_rc(ctx, hegel_settings_set_database_key(ctx, s, dbkey), "set_db_key"); + check_rc(ctx, hegel_settings_set_database(ctx, s, dbpath ? dbpath : ""), + "set_database"); + + check_rc(ctx, hegel_run_start(ctx, s, NULL, NULL, &run), "run_start"); + + skip_requested = false; + nonvacuous_opted_in = false; + nonvacuous_cases = 0; + + for (;;) + { + hegel_test_case_t *handle = NULL; + uint32_t status; + + check_rc(ctx, hegel_next_test_case(ctx, run, &handle), "next_test_case"); + if (handle == NULL) + break; + status = run_one_case(&rs, ctx, handle); + hegel_mark_complete(ctx, handle, status, + status == HEGEL_STATUS_INTERESTING ? prop->name : NULL); + hegel_test_case_free(ctx, handle); + } + + check_rc(ctx, hegel_run_result(ctx, run, &result), "run_result"); + check_rc(ctx, hegel_run_result_status(ctx, result, &rstatus), "run_result_status"); + + ok = (rstatus == HEGEL_RUN_STATUS_PASSED); + + /* + * The body called pg_prop_skip(): report the property as skipped (TAP + * SKIP directive) and count it as passed, regardless of the engine's + * status for the one case that ran before the skip. + */ + if (skip_requested) + { + const char *reason = skip_reason_inited ? skip_reason.data : ""; + + if (tap) + printf("ok %d - %s/%s # SKIP %s\n", testno, + prop->area, prop->name, reason); + else + printf("%s/%s: SKIPPED (%s)\n", prop->area, prop->name, reason); + hegel_run_result_free(ctx, result); + hegel_run_free(ctx, run); + hegel_settings_free(ctx, s); + hegel_context_free(ctx); + pfree(rs.last_fail.data); + return true; + } + + /* + * Vacuous-pass guard: a property that opted in (called pg_prop_nonvacuous + * in its live path) but reached that point in zero cases tested nothing, + * even though the engine reported PASSED. Fail it loudly rather than + * report a false green. A genuine failure (ok already false) is left + * alone. + */ + if (ok && nonvacuous_opted_in && nonvacuous_cases == 0) + { + ok = false; + vacuous_fail = true; + } + + if (tap) + printf("%s %d - %s/%s\n", ok ? "ok" : "not ok", testno, + prop->area, prop->name); + else + printf("%s/%s: %s\n", prop->area, prop->name, + ok ? "PASSED" : "FAILED"); + + if (!ok) + { + const char *pfx = tap ? "# " : " "; + + if (vacuous_fail) + { + printf("%svacuous: property ran 0 non-vacuous cases -- every case " + "returned early before its invariant check (database too " + "small, generated SQL invalid, or the server unreachable). " + "The run tested nothing; not reporting it as passed.\n", pfx); + } + else if (rstatus == HEGEL_RUN_STATUS_ERROR) + { + const char *err = NULL; + + hegel_run_result_error(ctx, result, &err); + printf("%srun error: %s\n", pfx, err ? err : "(unknown)"); + } + else + { + size_t count = 0; + + hegel_run_result_failure_count(ctx, result, &count); + for (size_t i = 0; i < count; i++) + { + hegel_failure_t *f = NULL; + const char *blob = NULL; + char *msg; + + hegel_run_result_failure(ctx, result, i, &f); + hegel_failure_reproduction_blob(ctx, f, &blob); + msg = replay_blob(&rs, ctx, s, blob); + + printf("%sfalsified: %s\n", pfx, msg); + if (blob) + printf("%sreplay: PG_PROP_SEED unavailable; rerun with " + "--blob to reproduce: %s\n", pfx, blob); + pg_free(msg); + hegel_failure_free(ctx, f); + } + } + } + fflush(stdout); + + pfree(rs.last_fail.data); + hegel_run_result_free(ctx, result); + hegel_run_free(ctx, run); + hegel_settings_free(ctx, s); + hegel_context_free(ctx); + return ok; +} + +/* ---- CLI ------------------------------------------------------------- */ + +static void +usage(const char *argv0) +{ + printf("Usage: %s AREA [options] [PROPERTY ...]\n\n", argv0); + printf(" AREA which group of properties to run\n"); + printf(" (run with --list to see the areas)\n"); + printf(" --list list AREA/PROPERTY names and exit\n"); + printf(" -n, --iterations N test cases per property (default 100)\n"); + printf(" -s, --seed N fixed RNG seed (default: random)\n"); + printf(" -v, --verbose raise engine verbosity\n"); + printf(" -q, --quiet lower engine verbosity\n"); + printf(" --no-tap plain output instead of TAP\n"); + printf(" -h, --help this help\n\n"); + printf("With no PROPERTY, every property in AREA runs. Examples:\n"); + printf(" %s jsonapi --list\n", argv0); + printf(" %s path canonicalize_idempotent -n 100000 -s 42\n\n", argv0); + printf("Environment overrides: PG_PROP_ITERATIONS, PG_PROP_SEED, PG_PROP_TESTS.\n"); +} + +/* Does name match any of the selectors? */ +static bool +selected(const char *name, char **sel, int nsel) +{ + if (nsel == 0) + return true; + for (int i = 0; i < nsel; i++) + if (strcmp(name, sel[i]) == 0) + return true; + return false; +} + +int +main(int argc, char **argv) +{ + static struct option long_options[] = { + {"list", no_argument, NULL, 'L'}, + {"iterations", required_argument, NULL, 'n'}, + {"seed", required_argument, NULL, 's'}, + {"verbose", no_argument, NULL, 'v'}, + {"quiet", no_argument, NULL, 'q'}, + {"no-tap", no_argument, NULL, 'T'}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + uint64 iterations = 100; + bool have_seed = false; + uint64 seed = 0; + bool do_list = false; + bool tap = true; + const char *env; + const char *dbpath; + const char *area = NULL; + int c; + int npass = 0; + int nrun = 0; + int nsel; + char **sel; + + pg_logging_init(argv[0]); + + /* environment defaults (command line wins) */ + if ((env = getenv("PG_PROP_ITERATIONS")) != NULL && env[0]) + iterations = strtou64(env, NULL, 10); + if ((env = getenv("PG_PROP_SEED")) != NULL && env[0]) + { + seed = strtou64(env, NULL, 10); + have_seed = true; + } + + while ((c = getopt_long(argc, argv, "n:s:vqhL", long_options, NULL)) != -1) + { + switch (c) + { + case 'L': + do_list = true; + break; + case 'n': + iterations = strtou64(optarg, NULL, 10); + break; + case 's': + seed = strtou64(optarg, NULL, 10); + have_seed = true; + break; + case 'v': + verbosity = HEGEL_VERBOSITY_VERBOSE; + break; + case 'q': + verbosity = HEGEL_VERBOSITY_QUIET; + break; + case 'T': + tap = false; + break; + case 'h': + usage(argv[0]); + return 0; + default: + usage(argv[0]); + return 2; + } + } + + if (do_list) + { + for (int i = 0; i < nprops; i++) + printf("%s/%s\n", props[i].area, props[i].name); + return 0; + } + + /* + * First positional argument selects the area (the target group). Running + * every area at once is intentionally not supported from one invocation: + * the TAP driver runs one area per binary subcommand, and a developer + * debugging a failure wants one area in front of them. + */ + if (optind >= argc) + { + fprintf(stderr, "pg_prop: missing AREA argument " + "(try --list, or -h for help)\n"); + return 2; + } + area = argv[optind++]; + + /* remaining positionals, else PG_PROP_TESTS, select properties by name */ + sel = &argv[optind]; + nsel = argc - optind; + if (nsel == 0 && (env = getenv("PG_PROP_TESTS")) != NULL && env[0]) + { + /* split PG_PROP_TESTS on whitespace into a fresh argv-like array */ + static char *envsel[MAX_PROPS]; + char *dup = pg_strdup(env); + char *tok = strtok(dup, " \t\n"); + + nsel = 0; + while (tok && nsel < MAX_PROPS) + { + envsel[nsel++] = tok; + tok = strtok(NULL, " \t\n"); + } + sel = envsel; + } + + /* + * Pick and announce a seed when none was given, so a failure seen on a + * buildfarm animal can be reproduced exactly. derandomize is left off: + * we want fresh randomness unless the caller pins a seed. + */ + if (!have_seed) + { + srandom((unsigned int) (getpid() ^ (unsigned int) time(NULL))); + seed = ((uint64) random() << 32) ^ (uint64) random(); + have_seed = true; + if (tap) + printf("# seed: %llu (rerun with -s %llu)\n", + (unsigned long long) seed, (unsigned long long) seed); + else + printf("seed: %llu\n", (unsigned long long) seed); + } + + /* + * The example database persists counterexamples between runs so a known + * failure replays first. Keep it out of the source tree: honour + * TESTDATADIR (set by the TAP wrapper) when present, else disable it. + */ + dbpath = getenv("PG_PROP_DB"); + if (dbpath == NULL) + { + const char *td = getenv("TESTDATADIR"); + + dbpath = td ? NULL : ""; /* NULL under TESTDATADIR -> default .hegel + * there; handled below */ + if (td) + { + static char dbbuf[MAXPGPATH]; + + snprintf(dbbuf, sizeof(dbbuf), "%s/hegel-db", td); + dbpath = dbbuf; + } + } + + /* count the selected properties for the TAP plan */ + if (tap) + { + int plan = 0; + + for (int i = 0; i < nprops; i++) + if (strcmp(props[i].area, area) == 0 && + selected(props[i].name, sel, nsel)) + plan++; + printf("1..%d\n", plan); + } + + for (int i = 0; i < nprops; i++) + { + if (strcmp(props[i].area, area) != 0) + continue; + if (!selected(props[i].name, sel, nsel)) + continue; + nrun++; + if (run_property(&props[i], iterations, have_seed, seed, dbpath, + nrun, tap)) + npass++; + } + + if (nrun == 0) + { + fprintf(stderr, "pg_prop: no matching properties in area \"%s\"\n", area); + return 2; + } + if (!tap) + printf("%d/%d properties passed\n", npass, nrun); + + return (npass == nrun) ? 0 : 1; +} diff --git a/src/test/prop/pg_prop.h b/src/test/prop/pg_prop.h new file mode 100644 index 00000000000..c3dca7da502 --- /dev/null +++ b/src/test/prop/pg_prop.h @@ -0,0 +1,381 @@ +/*------------------------------------------------------------------------- + * + * pg_prop.h + * Property-based testing harness for PostgreSQL, built on libhegel. + * + * This is the single entry point for every property-based test binary under + * src/test/prop. It wraps the libhegel C ABI (hegel.h) in a small, plain-C + * API so individual test files never touch the engine handles directly, and + * so a test file reads like an ordinary C unit test. + * + * A property is a function: + * + * static void prop_my_thing(pg_prop_tc *tc, void *arg) + * { + * int64 n = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + * if (!my_invariant(n)) + * pg_prop_fail(tc, "invariant violated for %lld", + * (long long) n); + * } + * + * registered with PG_PROP() and run from the generated main(). The engine + * generates many inputs, and on failure shrinks to a minimal counterexample + * and prints a base64 blob that replays it exactly. + * + * Why a C API and not hegel-cpp: PostgreSQL is a C project maintained by C + * programmers. libhegel ships a stable C ABI; we bind to it directly. The + * only C++-ism anywhere in this tree is that the engine's own shared library + * is built in Rust; nothing we write is C++. + * + * The isolate() facility (pg_prop_run_isolated) forks a child to run code + * that may crash, abort() on a failed Assert, or exit() on a fatal error -- + * all common in frontend PostgreSQL code -- and turns any of those into an + * ordinary property failure the engine can shrink. Without it, a crash in + * the code under test would take the whole test process down and lose the + * counterexample. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/pg_prop.h + * + *------------------------------------------------------------------------- + */ +#ifndef PG_PROP_H +#define PG_PROP_H + +#include "postgres_fe.h" + +/* + * Opaque per-test-case handle. One is handed to each property invocation; + * it must not be stored past the end of that invocation. + */ +typedef struct pg_prop_tc pg_prop_tc; + +/* A property function: draws inputs from tc, checks, calls pg_prop_fail. */ +typedef void (*pg_prop_fn) (pg_prop_tc *tc, void *arg); + +/* + * Register a property. Use the PG_PROP() macro rather than calling this + * directly. "area" groups properties that share a target (and a binary + * subcommand); "name" selects one on the command line and names it in the + * example database as "area/name"; "fn" is the property body. + */ +extern void pg_prop_register(const char *area, const char *name, + pg_prop_fn fn, void *arg); + +/* + * Each prop_.c defines PG_PROP_AREA to its area name before including + * this header: + * + * #define PG_PROP_AREA "path" + * #include "pg_prop.h" + * + * PG_PROP(ident) then defines and registers a property body under that area. + * Run it as: pg_prop [options] [name ...] + * + * The harness translation unit (pg_prop.c) includes this header without + * defining PG_PROP_AREA, which is fine -- it never uses PG_PROP(). Only a + * use of PG_PROP() without an area is an error, enforced below. + */ +#ifndef PG_PROP_AREA +#define PG_PROP_AREA pg_prop_area_not_defined +#endif + +#define PG_PROP(ident) \ + static void ident(pg_prop_tc *tc, void *arg); \ + static void pg_prop_reg_##ident(void) pg_attribute_constructor; \ + static void pg_prop_reg_##ident(void) \ + { \ + pg_prop_register(PG_PROP_AREA, CppAsString(ident), ident, NULL); \ + } \ + static void ident(pg_prop_tc *tc, void *arg) + +/* + * Extension points (hooks). + * + * Two ways an extension can participate in this suite: + * + * (a) Add a whole new area. Compile a prop_.c into the pg_prop binary + * (see src/test/prop/README, "Extensions") that defines PG_PROP_AREA to + * the extension's name and uses PG_PROP() exactly as a core area does. + * Nothing else is needed: the constructor registers it and it appears as + * its own subcommand. CREATE EXTENSION in the area's connect helper. + * + * (b) Augment an existing area. A core area may publish a named extension + * point and call pg_prop_run_hooks() at it; an extension registers a + * contributor with PG_PROP_HOOK(point). The contributor receives the + * point name and a void *ctx whose concrete type is documented by the + * point. For example, the SQL query generator (sql/query_gen.h) + * publishes "query_gen.column" and "query_gen.predicate" so an + * extension type can add columns and predicate leaves to the generated + * tables and queries, after which every query-level oracle (TLP, NoREC, + * cardinality, PQS, plan-advice) covers that type with no further work. + * + * A hook contributor: + * + * static void my_ext_cols(pg_prop_tc *tc, const char *point, void *ctx) + * { + * qg_schema *s = ctx; // concrete type for this point + * qg_add_column(s, "ck", "citext"); + * } + * PG_PROP_HOOK("query_gen.column", my_ext_cols); + */ +typedef void (*pg_prop_hook_fn) (pg_prop_tc *tc, const char *point, void *ctx); + +extern void pg_prop_register_hook(const char *point, pg_prop_hook_fn fn); +extern int pg_prop_hooks_count(const char *point); +extern void pg_prop_run_hooks(pg_prop_tc *tc, const char *point, void *ctx); +extern void pg_prop_run_hook_n(pg_prop_tc *tc, const char *point, int n, + void *ctx); + +/* + * Register a hook contributor before main(). "point" is the extension-point + * name; "fn" runs when the publishing area reaches that point. Place these at + * file scope in an extension's prop_.c. + */ +#define PG_PROP_HOOK(point, fn) \ + static void pg_prop_hookreg_##fn(void) pg_attribute_constructor; \ + static void pg_prop_hookreg_##fn(void) \ + { \ + pg_prop_register_hook((point), (fn)); \ + } \ + struct pg_prop_hook_force_semicolon_##fn +#ifndef pg_attribute_constructor +#define pg_attribute_constructor __attribute__((constructor)) +#endif + +/* + * Draw functions. Each pulls one value from the engine's choice stream for + * this test case. Bounds are inclusive. The engine deliberately favours + * boundary values (min, max, 0, -1, 1), so pass the widest bounds the code + * under test is supposed to accept -- narrowing them hides exactly the bugs + * this tool exists to find. + */ +extern int64 pg_prop_draw_int(pg_prop_tc *tc, int64 min, int64 max); +extern double pg_prop_draw_float(pg_prop_tc *tc, double min, double max); +extern bool pg_prop_draw_bool(pg_prop_tc *tc); + +/* + * Draw a byte buffer of length in [min_size, max_size]. The result is + * malloc'd and owned by the caller; free it. *len receives the length. The + * buffer is NUL-terminated for convenience (one extra byte past *len) but may + * contain interior NULs, so always use *len for the real content. + */ +extern unsigned char *pg_prop_draw_bytes(pg_prop_tc *tc, size_t min_size, + size_t max_size, size_t *len); + +/* + * Draw a UTF-8 string of length in [min_size, max_size] code points, drawn + * from the full assigned Unicode range (not ASCII-only). Caller frees. *len + * receives the byte length. NUL-terminated; may contain interior NULs. + */ +extern char *pg_prop_draw_utf8(pg_prop_tc *tc, size_t min_size, + size_t max_size, size_t *len); + +/* + * Draw a string restricted to the given alphabet (a NUL-terminated C string + * of allowed ASCII characters). Useful when the code under test is only + * meaningful on a restricted charset (identifiers, numeric text, ...). + * Caller frees. + */ +extern char *pg_prop_draw_from(pg_prop_tc *tc, const char *alphabet, + size_t min_size, size_t max_size, size_t *len); + +/* + * Reject the current test case: its generated inputs do not satisfy a + * precondition, so it should be discarded rather than counted. Use sparingly + * -- a high rejection rate trips a health check. Prefer constructing valid + * inputs directly over drawing-then-rejecting. + */ +extern void pg_prop_assume(pg_prop_tc *tc, bool condition); + +/* + * Skip the whole property: its precondition cannot be met in this environment + * (a required extension or procedural language is not installed, a platform + * feature is absent). Unlike pg_prop_assume -- which discards one generated + * case -- this abandons the entire property and reports it as skipped, with + * "reason" shown as the TAP SKIP directive. Call it at the top of the body, + * before drawing inputs. Does not return. + */ +extern pg_noreturn void pg_prop_skip(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3); + +/* + * Attach a diagnostic note to the current case. It is printed only when this + * case is the final shrunk counterexample, so use it to record context the + * drawn values alone don't show (e.g. an intermediate the property computed). + */ +extern void pg_prop_note(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3); + +/* + * Mark that this case did real work: it reached its invariant check (ran the + * query, compared the oracle) rather than returning early as vacuous (the + * generated database was too small to join, the drawn SQL did not parse, a + * guard bailed out). Call it at the point the case is about to assert its + * property, after any early-return guards. Pair it with + * pg_prop_opt_into_vacuous_guard() at the top of the body. + * + * Why this exists: a property that returns early on every case is counted by + * the engine as PASSED, because an early return is indistinguishable from "the + * invariant held". A validation run that tested nothing then reports success + * -- the worst failure mode a checker can have (it was a live bug: a startup + * slip left the server down, every deep case returned vacuous, and the suite + * reported all green while testing nothing). + */ +extern void pg_prop_nonvacuous(pg_prop_tc *tc); + +/* + * Declare that this property is subject to the vacuous-pass guard: call it + * once, unconditionally, at the top of the body (before any early-return + * guard). It arms the guard for the whole run; pg_prop_nonvacuous then marks + * each case that reached its invariant check. A run that armed the guard but + * marked zero non-vacuous cases is failed. + * + * Two calls rather than one because "this property wants the guard" and "this + * case did real work" are distinct: if a property only called nonvacuous and + * no case ever reached it, the harness could not tell an all-vacuous run + * (should fail) from a legacy property that never opted in (should pass). + * Arming it up front removes that ambiguity. + */ +extern void pg_prop_opt_into_vacuous_guard(pg_prop_tc *tc); + +/* + * Fail the current test case. This is how a property reports that its + * invariant did not hold. The message is the human-readable description of + * the failure; it is printed with the counterexample. Does not return. + */ +extern pg_noreturn void pg_prop_fail(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3); + +/* + * Run "body" in a forked child. Returns 0 if the child exited 0, otherwise a + * non-zero code describing how it died. *detail (if non-NULL) receives a + * malloc'd human-readable description ("exited N", "signal N (SIGSEGV)", ...). + * + * Use this to wrap any call into code that may crash, abort() on a failed + * assertion, or exit() -- so the failure becomes a value you can test and + * pg_prop_fail on, with the drawn inputs still in hand to shrink. Draw your + * inputs in the parent (before calling this) so the engine sees them; the + * child only runs the code under test. + */ +typedef void (*pg_prop_isolated_fn) (void *arg); +extern int pg_prop_run_isolated(pg_prop_isolated_fn body, void *arg, + char **detail); + +/* + * SQL oracle support (Tier B). + * + * Backend code -- the planner, executor, type I/O -- cannot be linked into a + * frontend binary, so properties that exercise it instead drive SQL against a + * live cluster and compare the answer to an oracle (a reference query, or the + * same query with an optimisation disabled). The TAP driver t/002_sql_props.pl + * starts one node and exports PGHOST/PGPORT; these helpers connect to it. + * + * A SQL area opens one connection in its property body (connections are cheap + * against a local socket and keeping them per-case keeps state from leaking + * between cases). The handle is an opaque PGconn-wrapper so pg_prop.h need + * not include libpq-fe.h. + */ +typedef struct pg_prop_conn pg_prop_conn; + +/* + * Ensure an extension (contrib module or procedural language) is available for + * the session, loading it with CREATE EXTENSION IF NOT EXISTS. Returns true + * if it is now present. If the extension is not installed on this server, + * returns false; the caller then typically pg_prop_skip()s. "conn" is an open + * connection. Example: + * + * if (!pg_prop_require_extension(conn, "pageinspect")) + * pg_prop_skip(tc, "pageinspect not installed"); + */ +extern bool pg_prop_require_extension(pg_prop_conn *conn, const char *extname); + +/* + * Connect to the test cluster named by PGHOST/PGPORT/PGDATABASE in the + * environment. Returns NULL and sets *err (malloc'd) on failure; the caller + * typically pg_prop_fail()s on NULL. Close with pg_prop_disconnect. + */ +extern pg_prop_conn *pg_prop_connect(char **err); +extern void pg_prop_disconnect(pg_prop_conn *conn); + +/* + * Run "sql" and return its single-row, single-column result as a malloc'd + * string (caller frees), or NULL for SQL NULL. On a SQL error, *errmsg (if + * non-NULL) receives a malloc'd message and the function returns NULL with + * *failed set true; callers distinguish a genuine NULL result from an error + * via *failed. Use pg_prop_sql_scalar for the common one-value oracle. + */ +extern char *pg_prop_sql_scalar(pg_prop_conn *conn, const char *sql, + bool *failed, char **errmsg); + +/* + * Run "sql" expecting a result set, and return the whole thing as one newline- + * joined, row-major string (every value rendered, NULLs as "\\N"), suitable for + * comparing two queries that should return identical rows in identical order. + * Caller frees. *failed / *errmsg as above. The caller is responsible for + * ordering (add ORDER BY) when it wants order-insensitive comparison use a + * sorted projection. + */ +extern char *pg_prop_sql_rows(pg_prop_conn *conn, const char *sql, + bool *failed, char **errmsg); + +/* + * Like pg_prop_sql_rows, but return an md5 digest of the whole result + * (computed server-side) instead of the full rendered text. Use this for + * oracles over large/deep results: the client holds only a 32-char hash, so a + * result that would overflow the 1 GB StringInfo cap when rendered + * client-side (the deep generators can produce such results) still compares in + * O(1) client memory. Two queries that return the same rows in the same order + * return the same digest; the caller supplies the ORDER BY that fixes the + * order. Caller frees; *failed / *errmsg as above. + */ +extern char *pg_prop_sql_rowhash(pg_prop_conn *conn, const char *sql, + bool *failed, char **errmsg); + +/* + * Like pg_prop_sql_rowhash but order-insensitive: the per-row text is sorted + * server-side before hashing, so two queries that return the same *multiset* + * of rows compare equal regardless of row order. Use this for any oracle + * that compares results across plans or query rewrites: SQL never guarantees + * row order without ORDER BY, and even with ORDER BY a tie among equal sort + * keys (equal numeric scales, a nondeterministic collation, a FULL/RIGHT join + * that null-extends every key) leaves the order unspecified. Comparing + * multisets removes that whole class of false positive (false_positives.md + * 2.1-2.2) and lets the generator emit shapes whose output has no total order. + * Counts and duplicates are preserved (it is a multiset, not a set). + */ +extern char *pg_prop_sql_rowhash_unordered(pg_prop_conn *conn, const char *sql, + bool *failed, char **errmsg); + +/* + * Run "sql" for side effects (DDL/DML), returning true on success. On error + * returns false and sets *errmsg (malloc'd). + */ +extern bool pg_prop_sql_exec(pg_prop_conn *conn, const char *sql, char **errmsg); + +/* "area/property" currently running (NULL outside a run); for diagnostics. */ +extern const char *pg_prop_current_area; + +/* + * Asynchronous statement support, for properties that need two sessions in + * flight at once (e.g. provoking a deadlock, where both sessions must be + * blocked before either can resolve). pg_prop_sql_send dispatches "sql" + * without waiting (returns false if it could not be sent); pg_prop_sql_await + * then blocks for the result, returning true on success and, on error, the + * SQLSTATE via *sqlstate (malloc'd 5-char code, or NULL) so the caller can + * tell a deadlock (40P01) from a serialization failure (40001) or a lock + * timeout (55P03). Use pg_prop_sql_send on both connections, then await each. + */ +extern bool pg_prop_sql_send(pg_prop_conn *conn, const char *sql); +extern bool pg_prop_sql_await(pg_prop_conn *conn, char **sqlstate, char **errmsg); + +/* + * Close any connections still open at the end of a test case. A property that + * fails or is assumed away unwinds by longjmp, jumping over its own + * pg_prop_disconnect(); the driver calls this after every case so a leaked + * connection cannot accumulate across the thousands of cases in a run (which + * would otherwise exhaust the server's connection slots). Defined in + * pg_prop_sql.c; a no-op if no SQL area ever connected. + */ +extern void pg_prop_sql_cleanup(void); + +#endif /* PG_PROP_H */ diff --git a/src/test/prop/pg_prop_sql.c b/src/test/prop/pg_prop_sql.c new file mode 100644 index 00000000000..24f8841a51d --- /dev/null +++ b/src/test/prop/pg_prop_sql.c @@ -0,0 +1,409 @@ +/*------------------------------------------------------------------------- + * + * pg_prop_sql.c + * libpq oracle helpers for Tier-B property tests. + * + * Backend code (planner, executor, type I/O) cannot be linked into a frontend + * binary, so properties that exercise it drive SQL against a live cluster and + * compare the result to an oracle. These helpers wrap just enough of libpq + * for that: connect, run a scalar/rowset/DDL query, render the result as a + * string the property can compare. See pg_prop.h for the contract. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/pg_prop_sql.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#include +#include + +#include "lib/stringinfo.h" +#include "libpq-fe.h" +#include "pg_prop.h" + +struct pg_prop_conn +{ + PGconn *conn; + struct pg_prop_conn *next; /* link in the live-connection registry */ +}; + +/* + * Live connections, so the driver can close any a failing/assumed-away case + * leaked by longjmp'ing over its pg_prop_disconnect(). One run is single- + * threaded, so a plain list needs no locking. + */ +static pg_prop_conn *live_conns = NULL; + +pg_prop_conn * +pg_prop_connect(char **err) +{ + pg_prop_conn *c; + PGconn *conn; + + /* + * Empty connection string: everything comes from the environment + * (PGHOST, PGPORT, PGDATABASE), which the TAP driver points at the test + * node. dbname defaults to "postgres" if PGDATABASE is unset. + */ + const char *keywords[] = {"dbname", NULL}; + const char *values[] = {getenv("PGDATABASE") ? getenv("PGDATABASE") : "postgres", NULL}; + + conn = PQconnectdbParams(keywords, values, true); + if (PQstatus(conn) != CONNECTION_OK) + { + if (err) + *err = pg_strdup(PQerrorMessage(conn)); + PQfinish(conn); + return NULL; + } + c = pg_malloc(sizeof(pg_prop_conn)); + c->conn = conn; + c->next = live_conns; + live_conns = c; + + /* Quieten DROP ... IF EXISTS notices and similar chatter. */ + PQclear(PQexec(conn, "SET client_min_messages = warning")); + + return c; +} + +/* Unlink c from the live list, if present. */ +static void +unlink_conn(pg_prop_conn *c); + +/* + * Crash tripwire. A backend that dies (assertion failure, segfault) takes + * its connection with it, and the property would otherwise see only a + * confusing SQL failure, which the engine then "shrinks" by trying other + * inputs until the case stops reproducing -- by which time the postmaster is + * in crash recovery and every following area fails to connect. So every + * statement helper calls this after PQexec: if the connection is now bad, + * the statement that killed it is appended to $PG_PROP_CRASH_LOG (default + * pg_prop_crashes.sql in the working directory), stamped with the area + * and time, and the run aborts at once with a distinct exit code. The + * crashing statement is the reproducer; it is captured before shrinking can + * lose it, and nothing downstream runs against a recovering server. + */ +static void +check_backend_alive(pg_prop_conn *conn, const char *sql) +{ + FILE *f; + const char *path; + time_t now; + + if (PQstatus(conn->conn) == CONNECTION_OK) + return; + + path = getenv("PG_PROP_CRASH_LOG"); + if (path == NULL || path[0] == '\0') + path = "pg_prop_crashes.sql"; + now = time(NULL); + f = fopen(path, "a"); + if (f != NULL) + { + fprintf(f, "-- backend died: area=%s pid=%d at %s" + "-- server said: %s\n%s;\n\n", + pg_prop_current_area ? pg_prop_current_area : "?", + (int) getpid(), ctime(&now), + PQerrorMessage(conn->conn), sql); + fclose(f); + } + fprintf(stderr, + "pg_prop: BACKEND CRASH -- the server connection died while running:\n" + "%s\n(statement saved to %s; aborting so no further area runs " + "against a recovering server)\n", sql, path); + fflush(stdout); + exit(3); +} + +/* Unlink c from the live list, if present. */ +static void +unlink_conn(pg_prop_conn *c) +{ + pg_prop_conn **pp = &live_conns; + + while (*pp != NULL) + { + if (*pp == c) + { + *pp = c->next; + return; + } + pp = &(*pp)->next; + } +} + +void +pg_prop_disconnect(pg_prop_conn *conn) +{ + if (conn == NULL) + return; + unlink_conn(conn); + PQfinish(conn->conn); + pg_free(conn); +} + +void +pg_prop_sql_cleanup(void) +{ + while (live_conns != NULL) + { + pg_prop_conn *c = live_conns; + + live_conns = c->next; + PQfinish(c->conn); + pg_free(c); + } +} + +char * +pg_prop_sql_scalar(pg_prop_conn *conn, const char *sql, bool *failed, char **errmsg) +{ + PGresult *res = PQexec(conn->conn, sql); + ExecStatusType st; + char *out; + + check_backend_alive(conn, sql); + st = PQresultStatus(res); + + if (failed) + *failed = false; + if (st != PGRES_TUPLES_OK) + { + if (failed) + *failed = true; + if (errmsg) + *errmsg = pg_strdup(PQresultErrorMessage(res)); + PQclear(res); + return NULL; + } + if (PQntuples(res) == 0 || PQnfields(res) == 0 || PQgetisnull(res, 0, 0)) + { + PQclear(res); + return NULL; /* genuine NULL / empty, *failed stays false */ + } + out = pg_strdup(PQgetvalue(res, 0, 0)); + PQclear(res); + return out; +} + +char * +pg_prop_sql_rows(pg_prop_conn *conn, const char *sql, bool *failed, char **errmsg) +{ + PGresult *res = PQexec(conn->conn, sql); + ExecStatusType st; + StringInfoData s; + int nrows, + ncols; + + check_backend_alive(conn, sql); + st = PQresultStatus(res); + + if (failed) + *failed = false; + if (st != PGRES_TUPLES_OK) + { + if (failed) + *failed = true; + if (errmsg) + *errmsg = pg_strdup(PQresultErrorMessage(res)); + PQclear(res); + return NULL; + } + + initStringInfo(&s); + nrows = PQntuples(res); + ncols = PQnfields(res); + for (int r = 0; r < nrows; r++) + { + for (int col = 0; col < ncols; col++) + { + if (col > 0) + appendStringInfoChar(&s, '\t'); + if (PQgetisnull(res, r, col)) + appendStringInfoString(&s, "\\N"); + else + appendStringInfoString(&s, PQgetvalue(res, r, col)); + } + appendStringInfoChar(&s, '\n'); + } + PQclear(res); + return s.data; /* caller frees */ +} + +/* + * Shared body for the two rowhash variants. Digests the whole result + * server-side: md5 of the newline-joined per-row text. Returning a 32-char + * hash instead of the full result keeps client memory O(1) regardless of + * result size (the deep generators can produce results that overflow the + * 1 GB StringInfo cap when rendered client-side; see finding 027). When + * "ordered" is false the per-row text is sorted first, so the digest is of + * the row multiset rather than the row sequence. An empty result hashes + * md5('') so two empty results compare equal. + */ +static char * +rowhash_body(pg_prop_conn *conn, const char *sql, bool ordered, + bool *failed, char **errmsg) +{ + StringInfoData q; + PGresult *res; + ExecStatusType st; + char *out; + + if (failed) + *failed = false; + + initStringInfo(&q); + if (ordered) + appendStringInfo(&q, + "SELECT md5(coalesce(string_agg(q.r, E'\\n'), '')) " + "FROM (SELECT (sub)::text AS r FROM (%s) sub) q", + sql); + else + appendStringInfo(&q, + "SELECT md5(coalesce(string_agg(q.r, E'\\n' ORDER BY q.r), '')) " + "FROM (SELECT (sub)::text AS r FROM (%s) sub) q", + sql); + res = PQexec(conn->conn, q.data); + check_backend_alive(conn, sql); + pfree(q.data); + st = PQresultStatus(res); + if (st != PGRES_TUPLES_OK) + { + if (failed) + *failed = true; + if (errmsg) + *errmsg = pg_strdup(PQresultErrorMessage(res)); + PQclear(res); + return NULL; + } + if (PQntuples(res) == 0 || PQgetisnull(res, 0, 0)) + { + PQclear(res); + return pg_strdup(""); /* no rows -> empty digest */ + } + out = pg_strdup(PQgetvalue(res, 0, 0)); + PQclear(res); + return out; +} + +char * +pg_prop_sql_rowhash(pg_prop_conn *conn, const char *sql, bool *failed, + char **errmsg) +{ + return rowhash_body(conn, sql, true, failed, errmsg); +} + +char * +pg_prop_sql_rowhash_unordered(pg_prop_conn *conn, const char *sql, + bool *failed, char **errmsg) +{ + return rowhash_body(conn, sql, false, failed, errmsg); +} + +bool +pg_prop_sql_exec(pg_prop_conn *conn, const char *sql, char **errmsg) +{ + PGresult *res = PQexec(conn->conn, sql); + ExecStatusType st; + + check_backend_alive(conn, sql); + st = PQresultStatus(res); + + if (st != PGRES_COMMAND_OK && st != PGRES_TUPLES_OK) + { + if (errmsg) + *errmsg = pg_strdup(PQresultErrorMessage(res)); + PQclear(res); + return false; + } + PQclear(res); + return true; +} + +/* + * Load an extension (contrib module or procedural language) into the session, + * returning true if it is now available. Checks pg_available_extensions first + * so a missing module is reported as "unavailable" (false) rather than a hard + * SQL error; if present, runs CREATE EXTENSION IF NOT EXISTS. Used by areas + * that test an extension so they can pg_prop_skip() cleanly when it is not + * installed on the server under test. + */ +bool +pg_prop_require_extension(pg_prop_conn *conn, const char *extname) +{ + PGresult *res; + char q[256]; + bool avail; + + /* is it installable here at all? */ + snprintf(q, sizeof(q), + "SELECT 1 FROM pg_available_extensions WHERE name = '%s'", + extname); + res = PQexec(conn->conn, q); + avail = (PQresultStatus(res) == PGRES_TUPLES_OK && PQntuples(res) == 1); + PQclear(res); + if (!avail) + return false; + + snprintf(q, sizeof(q), "CREATE EXTENSION IF NOT EXISTS \"%s\"", extname); + res = PQexec(conn->conn, q); + avail = (PQresultStatus(res) == PGRES_COMMAND_OK || + PQresultStatus(res) == PGRES_TUPLES_OK); + PQclear(res); + return avail; +} + +/* + * Asynchronous statement support, for properties that need two sessions in + * flight at once (e.g. provoking a deadlock, where both must be blocked before + * either can resolve). pg_prop_sql_send dispatches a statement without waiting + * for its result; pg_prop_sql_await then blocks for the result and reports + * whether it succeeded, returning the SQLSTATE (a malloc'd 5-char code, or NULL) + * via *sqlstate so the caller can distinguish, e.g., a deadlock (40P01) from a + * serialization failure (40001). + */ +bool +pg_prop_sql_send(pg_prop_conn *conn, const char *sql) +{ + return PQsendQuery(conn->conn, sql) == 1; +} + +bool +pg_prop_sql_await(pg_prop_conn *conn, char **sqlstate, char **errmsg) +{ + PGresult *res; + bool ok = true; + + if (sqlstate) + *sqlstate = NULL; + if (errmsg) + *errmsg = NULL; + + /* drain all results for the dispatched command(s) */ + while ((res = PQgetResult(conn->conn)) != NULL) + { + ExecStatusType st = PQresultStatus(res); + + if (st != PGRES_COMMAND_OK && st != PGRES_TUPLES_OK && + st != PGRES_EMPTY_QUERY) + { + ok = false; + if (sqlstate && *sqlstate == NULL) + { + char *s = PQresultErrorField(res, PG_DIAG_SQLSTATE); + + if (s) + *sqlstate = pg_strdup(s); + } + if (errmsg && *errmsg == NULL) + *errmsg = pg_strdup(PQresultErrorMessage(res)); + } + PQclear(res); + } + return ok; +} diff --git a/src/test/prop/prop_base64.c b/src/test/prop/prop_base64.c new file mode 100644 index 00000000000..47b99166ba0 --- /dev/null +++ b/src/test/prop/prop_base64.c @@ -0,0 +1,99 @@ +/*------------------------------------------------------------------------- + * + * prop_base64.c + * Property-based tests for src/common/base64.c. + * + * base64 encode/decode is a pure codec with a clean round-trip property and + * explicit length contracts (pg_b64_enc_len / pg_b64_dec_len), used for SCRAM, + * the COPY and replication protocols, and more. A codec is the textbook case + * for property testing: encode-then-decode must reproduce the input for every + * byte string, and the length helpers must bound the real output. + * + * Tier A: in-process, no server needed. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_base64.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "base64" +#include "pg_prop.h" + +#include "common/base64.h" + +PG_PROP(roundtrip) +{ + size_t len; + unsigned char *src = pg_prop_draw_bytes(tc, 0, 256, &len); + int enclen; + int declen; + char *enc; + unsigned char *dec; + + /* encode into a buffer sized by the library's own length helper */ + enc = pg_malloc(pg_b64_enc_len((int) len) + 1); + enclen = pg_b64_encode(src, (int) len, enc, pg_b64_enc_len((int) len)); + if (enclen < 0) + pg_prop_fail(tc, "encode failed for len=%zu", len); + + /* decode back; the result must be byte-identical to the input */ + dec = pg_malloc(pg_b64_dec_len(enclen) + 1); + declen = pg_b64_decode(enc, enclen, dec, pg_b64_dec_len(enclen)); + if (declen < 0) + pg_prop_fail(tc, "decode failed for len=%zu enclen=%d", len, enclen); + + if (declen != (int) len || memcmp(dec, src, len) != 0) + pg_prop_fail(tc, "roundtrip mismatch: len=%zu declen=%d", len, declen); + + pg_free(dec); + pg_free(enc); + pg_free(src); +} + +PG_PROP(enc_len_bounds_output) +{ + size_t len; + unsigned char *src = pg_prop_draw_bytes(tc, 0, 256, &len); + int bound = pg_b64_enc_len((int) len); + char *enc = pg_malloc(bound + 1); + int enclen = pg_b64_encode(src, (int) len, enc, bound); + + /* + * pg_b64_enc_len() is what every caller uses to size the destination, so + * the actual encoded length must never exceed it. + */ + if (enclen < 0 || enclen > bound) + pg_prop_fail(tc, "encoded length %d exceeds pg_b64_enc_len bound %d (input len=%zu)", + enclen, bound, len); + + pg_free(enc); + pg_free(src); +} + +PG_PROP(decode_garbage_no_overrun) +{ + size_t len; + char *src = pg_prop_draw_from(tc, + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz0123456789+/= \n", + 0, 256, &len); + int bound = pg_b64_dec_len((int) len); + unsigned char *dst = pg_malloc(bound > 0 ? bound + 1 : 1); + int declen = pg_b64_decode(src, (int) len, dst, bound); + + /* + * Arbitrary base64-alphabet-ish input either decodes to at most the + * length pg_b64_dec_len() promised, or is rejected (< 0); it must never + * report writing more than the buffer bound. + */ + if (declen > bound) + pg_prop_fail(tc, "decoded length %d exceeds pg_b64_dec_len bound %d (input len=%zu)", + declen, bound, len); + + pg_free(dst); + pg_free(src); +} diff --git a/src/test/prop/prop_bytea.c b/src/test/prop/prop_bytea.c new file mode 100644 index 00000000000..d2ff02f62ca --- /dev/null +++ b/src/test/prop/prop_bytea.c @@ -0,0 +1,320 @@ +/*------------------------------------------------------------------------- + * + * prop_bytea.c + * Property-based tests for bytea and encode()/decode(). + * + * encode()/decode() (hex, base64, escape) and bytea operations are codecs + * with exact round-trip properties plus length/concatenation laws; this + * exercises all three encodings over arbitrary bytes. The targets are + * src/backend/utils/adt/encode.c (the hex/base64/escape codecs) and + * varlena.c (bytea length, concatenation and substring). + * + * The oracle is the type's own algebra: for every encoding, decode . encode + * is the identity on bytea; length() of a bytea is its byte count; + * concatenation adds lengths; and splitting a bytea at any point and + * rejoining the pieces reconstructs it. None of these admit any tolerance, + * so any "f" result is a bug in the codec or in the bytea operators. + * + * Tier B: this runs SQL against the cluster the TAP driver started; see + * pg_prop.h and t/002_sql_props.pl. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_bytea.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "bytea" +#include "pg_prop.h" + +#include "lib/stringinfo.h" + +/* + * Draw 0..128 arbitrary bytes and append a bytea literal for them into buf, + * in the hex input format: '\x'::bytea with two hex chars per + * byte. The leading sequence is a literal backslash followed by 'x', which + * is how bytea's hex input is written under the default + * standard_conforming_strings=on (the backslash is a literal character in the + * single-quoted string, not an escape). The empty bytea '\x'::bytea is a + * valid and interesting case and is included (nbytes may be 0). + * + * *nbytes receives the number of bytes drawn, so a property can compare SQL + * length() against the exact count it generated. + */ +static void +draw_bytea_literal(pg_prop_tc *tc, StringInfo buf, size_t *nbytes) +{ + size_t len; + unsigned char *bytes = pg_prop_draw_bytes(tc, 0, 128, &len); + + appendStringInfoString(buf, "'\\x"); + for (size_t i = 0; i < len; i++) + appendStringInfo(buf, "%02x", bytes[i]); + appendStringInfoString(buf, "'::bytea"); + + *nbytes = len; + pg_free(bytes); +} + +/* + * Run a boolean-valued SELECT and require it to return "t". A NULL result or + * a SQL error means the codec or operator errored or produced NULL on valid + * inputs, which is itself a bug -- so either is a failure, reported with the + * SQL. + */ +static void +require_true(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql) +{ + bool failed = false; + char *err = NULL; + char *res; + + res = pg_prop_sql_scalar(conn, sql, &failed, &err); + if (failed) + { + pg_prop_fail(tc, "SQL error for: %s\n error: %s", + sql, err ? err : "(unknown)"); + } + if (res == NULL) + { + pg_prop_fail(tc, "unexpected NULL result for: %s", sql); + } + if (strcmp(res, "t") != 0) + { + pg_prop_fail(tc, "invariant failed (got %s) for: %s", res, sql); + } + pg_free(res); + if (err) + pg_free(err); +} + +/* + * Hex round-trip: decode(encode(B, 'hex'), 'hex') = B for every byte string. + * encode() renders each byte as two lowercase hex digits and decode() parses + * them back, so this is the identity on bytea with no exceptions. + */ +PG_PROP(hex_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT decode(encode(%s, 'hex'), 'hex') = %s", + b.data, b.data); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Base64 round-trip: decode(encode(B, 'base64'), 'base64') = B. base64 is a + * pure codec, so encode-then-decode must reproduce every input exactly. + */ +PG_PROP(base64_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT decode(encode(%s, 'base64'), 'base64') = %s", + b.data, b.data); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Escape round-trip: decode(encode(B, 'escape'), 'escape') = B. The 'escape' + * format leaves printable non-backslash bytes as-is and octal-escapes the + * rest (and doubles backslashes), which is a known fiddly path -- a boundary + * bug there would show up as a failed round-trip here. + */ +PG_PROP(escape_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT decode(encode(%s, 'escape'), 'escape') = %s", + b.data, b.data); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Length matches the drawn byte count: length(B) = . For bytea, + * length() returns the number of bytes, which must equal the count this + * property generated into the literal. + */ +PG_PROP(length_matches_byte_count) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT length(%s) = %zu", b.data, nbytes); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Concatenation is length-additive: length(B1 || B2) = length(B1) + + * length(B2). Appending two bytea values must produce exactly the sum of + * their lengths, with no truncation or padding. + */ +PG_PROP(concat_length_additive) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t n1, + n2; + StringInfoData b1, + b2, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b1); + initStringInfo(&b2); + draw_bytea_literal(tc, &b1, &n1); + draw_bytea_literal(tc, &b2, &n2); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT length(%s || %s) = length(%s) + length(%s)", + b1.data, b2.data, b1.data, b2.data); + + require_true(tc, conn, q.data); + + pfree(b1.data); + pfree(b2.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Split-and-rejoin reconstructs: substr(B, 1, k) || substr(B, k+1) = B for + * any split point k in 0..length(B). The split point is clamped in SQL with + * least(, length(B)) so the drawn index can never run past the end + * and the oracle matches regardless of the generated byte count. + */ +PG_PROP(substr_concat_reconstructs) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + int64 k; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + k = pg_prop_draw_int(tc, 0, 128); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT substr(%s, 1, least(%lld, length(%s))) " + "|| substr(%s, least(%lld, length(%s)) + 1) = %s", + b.data, (long long) k, b.data, + b.data, (long long) k, b.data, b.data); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Hex encoding length: length(encode(B, 'hex')) = 2 * . The hex + * encoding emits exactly two characters per input byte, so the encoded text + * length is twice the byte count with no exceptions. + */ +PG_PROP(hex_encode_length) +{ + pg_prop_conn *conn; + char *err = NULL; + size_t nbytes; + StringInfoData b, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&b); + draw_bytea_literal(tc, &b, &nbytes); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT length(encode(%s, 'hex')) = %zu", + b.data, (size_t) 2 * nbytes); + + require_true(tc, conn, q.data); + + pfree(b.data); + pfree(q.data); + pg_prop_disconnect(conn); +} diff --git a/src/test/prop/prop_intervalio.c b/src/test/prop/prop_intervalio.c new file mode 100644 index 00000000000..4ba81325d82 --- /dev/null +++ b/src/test/prop/prop_intervalio.c @@ -0,0 +1,312 @@ +/*------------------------------------------------------------------------- + * + * prop_intervalio.c + * Property-based tests for interval text I/O (ISO 8601 and verbose). + * + * The oracle is round-trip identity through interval's textual + * representation: an interval rendered to text and parsed back must equal + * itself, and an ISO 8601 literal ('P1DT2H') must denote the same value + * make_interval() builds from the same fields. All intervals here are + * month-free (days/hours/mins/secs only), so epoch extraction is exact + * (a day is 86400s) and reconstruction from fields is lossless. A broken + * round-trip is a bug in interval_in/interval_out or the ISO/verbose + * parsing, distinct from interval arithmetic, parts, and comparison. + * + * Tier B: runs SQL against the cluster the TAP driver started; see + * pg_prop.h and t/002_sql_props.pl. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_intervalio.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "intervalio" +#include "pg_prop.h" + +#include "lib/stringinfo.h" + +/* + * Run a boolean-valued SELECT and require it to return "t". A NULL result or + * a SQL error means interval I/O errored or produced NULL on valid inputs, + * which is itself a bug -- so either is a failure, reported with the SQL. + */ +static void +require_true(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql) +{ + bool failed = false; + char *err = NULL; + char *res; + + res = pg_prop_sql_scalar(conn, sql, &failed, &err); + if (failed) + { + pg_prop_fail(tc, "SQL error for: %s\n error: %s", + sql, err ? err : "(unknown)"); + } + if (res == NULL) + { + pg_prop_fail(tc, "unexpected NULL result for: %s", sql); + } + if (strcmp(res, "t") != 0) + { + pg_prop_fail(tc, "invariant failed (got %s) for: %s", res, sql); + } + pg_free(res); + if (err) + pg_free(err); +} + +/* + * Draw a month-free make_interval(...) expression into buf from small field + * counts: days, hours, mins, secs. Months are deliberately excluded so epoch + * is exact and field reconstruction is lossless. Returns the drawn fields + * through the out-params so callers can also build an epoch expectation. + */ +static void +draw_make_interval(pg_prop_tc *tc, StringInfo buf, + int *d, int *h, int *m, int *s) +{ + *d = (int) pg_prop_draw_int(tc, -5, 5); + *h = (int) pg_prop_draw_int(tc, -24, 24); + *m = (int) pg_prop_draw_int(tc, -59, 59); + *s = (int) pg_prop_draw_int(tc, -59, 59); + + appendStringInfo(buf, + "make_interval(days=>(%d), hours=>(%d), mins=>(%d), secs=>(%d))", + *d, *h, *m, *s); +} + +/* + * Property 1: text round-trip. Rendering an interval to text and parsing it + * back must yield the original interval. + */ +PG_PROP(text_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData iv, + q; + int d, + h, + m, + s; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&iv); + draw_make_interval(tc, &iv, &d, &h, &m, &s); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ((%s)::text::interval = %s)", + iv.data, iv.data); + + require_true(tc, conn, q.data); + + pfree(iv.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 2: ISO 8601 round-trip. An ISO interval literal must denote the + * same value make_interval() builds from the same days/hours fields. + */ +PG_PROP(iso8601_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData q; + int d, + h; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + d = (int) pg_prop_draw_int(tc, 0, 9); + h = (int) pg_prop_draw_int(tc, 0, 23); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT ('P%dDT%dH'::interval = make_interval(days=>(%d), hours=>(%d)))", + d, h, d, h); + + require_true(tc, conn, q.data); + + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 3: justify_hours output is stable. Rendering justify_hours(I) to + * text and parsing it back must equal justify_hours(I). + */ +PG_PROP(justify_text_stable) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData iv, + q; + int d, + h, + m, + s; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&iv); + draw_make_interval(tc, &iv, &d, &h, &m, &s); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT (justify_hours(%s)::text::interval = justify_hours(%s))", + iv.data, iv.data); + + require_true(tc, conn, q.data); + + pfree(iv.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 4: seconds literal round-trip. interval ' seconds' must equal + * make_interval(secs=>) for n in -3600..3600. + */ +PG_PROP(seconds_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData q; + int n; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + n = (int) pg_prop_draw_int(tc, -3600, 3600); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT (interval '%d seconds' = make_interval(secs=>(%d)))", + n, n); + + require_true(tc, conn, q.data); + + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 5: negative interval round-trip. With a strictly negative hours + * field, rendering to text and parsing back must yield the original interval. + */ +PG_PROP(negative_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData iv, + q; + int h; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + h = (int) pg_prop_draw_int(tc, -48, -1); + + initStringInfo(&iv); + appendStringInfo(&iv, "make_interval(hours=>(%d))", h); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ((%s)::text::interval = %s)", + iv.data, iv.data); + + require_true(tc, conn, q.data); + + pfree(iv.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 6: epoch is exact for month-free intervals. extract(epoch from I) + * must equal d*86400 + h*3600 + m*60 + s for the drawn fields (an interval day + * is exactly 86400s; no months means no variable-length month to make this + * approximate). + */ +PG_PROP(extract_epoch_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData iv, + q; + int d, + h, + m, + s; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&iv); + draw_make_interval(tc, &iv, &d, &h, &m, &s); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT (extract(epoch from %s) = (%d::bigint*86400 + %d::bigint*3600 + %d::bigint*60 + %d::bigint))", + iv.data, d, h, m, s); + + require_true(tc, conn, q.data); + + pfree(iv.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Property 7: the zero interval. interval '0' must equal make_interval() and + * must equal the difference of an interval with itself. + */ +PG_PROP(zero_interval) +{ + pg_prop_conn *conn; + char *err = NULL; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + require_true(tc, conn, + "SELECT (interval '0' = make_interval() " + "AND interval '0' = (make_interval(hours=>1) - make_interval(hours=>1)))"); + + pg_prop_disconnect(conn); +} + +/* + * Property 8: ISO zero output parses to the zero interval. 'P0D'::interval + * must equal interval '0'. + */ +PG_PROP(iso_output_parse) +{ + pg_prop_conn *conn; + char *err = NULL; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + require_true(tc, conn, "SELECT ('P0D'::interval = interval '0')"); + + pg_prop_disconnect(conn); +} diff --git a/src/test/prop/prop_jsonbroundtrip.c b/src/test/prop/prop_jsonbroundtrip.c new file mode 100644 index 00000000000..36ba513751f --- /dev/null +++ b/src/test/prop/prop_jsonbroundtrip.c @@ -0,0 +1,373 @@ +/*------------------------------------------------------------------------- + * + * prop_jsonbroundtrip.c + * Property-based tests for full jsonb value round-trips. + * + * The oracle is identity: a jsonb value J serialized to text (or pretty text, + * or concatenated with an empty object) and parsed back must equal J. Nested + * objects and arrays with integer values are built in C and substituted into + * SQL. Any "f" is a serialize/parse asymmetry in jsonb I/O -- key ordering, + * number canonicalization, whitespace, or structural handling -- which is a + * bug. Distinct from jsonformat/jsonbnum/jsonbtype: those check pretty-print + * dedup, number canonicalization, and type-of extraction respectively; this + * area asserts whole-value identity across text/pretty/concat/strip_nulls on + * built nested shapes. + * + * Tier B: runs SQL against the cluster the TAP driver started; see + * pg_prop.h and t/002_sql_props.pl. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_jsonbroundtrip.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "jsonbroundtrip" +#include "pg_prop.h" + +#include "lib/stringinfo.h" + +/* + * Run a boolean-valued SELECT and require it to return "t". A NULL result or + * a SQL error means the round-trip errored or produced NULL, which is itself + * a failure, reported with the SQL. (Same shape as prop_numeric.c.) + */ +static void +require_true(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql) +{ + bool failed = false; + char *err = NULL; + char *res; + + res = pg_prop_sql_scalar(conn, sql, &failed, &err); + if (failed) + { + pg_prop_fail(tc, "SQL error for: %s\n error: %s", + sql, err ? err : "(unknown)"); + } + if (res == NULL) + { + pg_prop_fail(tc, "unexpected NULL result for: %s", sql); + } + if (strcmp(res, "t") != 0) + { + pg_prop_fail(tc, "invariant failed (got %s) for: %s", res, sql); + } + pg_free(res); + if (err) + pg_free(err); +} + +/* + * Draw a signed integer and append it as a JSON number into buf. Negative + * values are not special inside a JSON object/array literal (no cast is + * applied here), but the matching to_jsonb() cases parenthesize so a leading + * '-' never misparses -- see draw_jsonb_scalar_arg. + */ +static void +append_json_int(pg_prop_tc *tc, StringInfo buf) +{ + int64 n = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + + appendStringInfo(buf, INT64_FORMAT, n); +} + +/* + * Build a jsonb object literal with a-c keys and integer values, plus one + * nested array of integers, into buf as a single-quoted SQL text literal cast + * to jsonb. Shape A: {"a":,"b":,...,"arr":[,...]}. Keys are a fixed + * ASCII alphabet so no quoting hazard arises; values are plain JSON numbers so + * the literal is always valid JSON. + */ +static void +build_jsonb_object(pg_prop_tc *tc, StringInfo buf) +{ + static const char *const keys[] = {"a", "b", "c"}; + int nkeys = (int) pg_prop_draw_int(tc, 1, 3); + int narr = (int) pg_prop_draw_int(tc, 0, 4); + int i; + + appendStringInfoString(buf, "'{"); + for (i = 0; i < nkeys; i++) + { + if (i > 0) + appendStringInfoChar(buf, ','); + appendStringInfo(buf, "\"%s\":", keys[i]); + append_json_int(tc, buf); + } + /* one nested array of integers */ + if (nkeys > 0) + appendStringInfoChar(buf, ','); + appendStringInfoString(buf, "\"arr\":["); + for (i = 0; i < narr; i++) + { + if (i > 0) + appendStringInfoChar(buf, ','); + append_json_int(tc, buf); + } + appendStringInfoString(buf, "]}'::jsonb"); +} + +/* + * Build a jsonb array literal of integers into buf as a single-quoted SQL + * text literal cast to jsonb: '[,,...]'::jsonb. + */ +static void +build_jsonb_array(pg_prop_tc *tc, StringInfo buf) +{ + int n = (int) pg_prop_draw_int(tc, 0, 6); + int i; + + appendStringInfoString(buf, "'["); + for (i = 0; i < n; i++) + { + if (i > 0) + appendStringInfoChar(buf, ','); + append_json_int(tc, buf); + } + appendStringInfoString(buf, "]'::jsonb"); +} + +/* + * J::text::jsonb = J for a built object. Serializing a jsonb object to text + * and parsing it back must reproduce the identical value. + */ +PG_PROP(text_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + build_jsonb_object(tc, &j); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT (%s)::text::jsonb = (%s)", j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * jsonb_pretty(J)::jsonb = J. Pretty-printing adds whitespace/indentation + * only; reparsing must recover J exactly. + */ +PG_PROP(pretty_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + build_jsonb_object(tc, &j); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT jsonb_pretty(%s)::jsonb = (%s)", + j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * (J || '{}'::jsonb)::text::jsonb = J. Concatenating an empty object is a + * no-op on an object value, so the full text round-trip must still equal J. + */ +PG_PROP(concat_empty_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + build_jsonb_object(tc, &j); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT ((%s) || '{}'::jsonb)::text::jsonb = (%s)", + j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * For J = {"o":{"i":},"a":[1,2,3]}, J::text::jsonb = J. A deliberately + * nested object-in-object plus array value exercises recursive serialize and + * parse together. + */ +PG_PROP(nested_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + appendStringInfoString(&j, "'{\"o\":{\"i\":"); + append_json_int(tc, &j); + appendStringInfoString(&j, "},\"a\":[1,2,3]}'::jsonb"); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT (%s)::text::jsonb = (%s)", j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * For a jsonb array A, A::text::jsonb = A. Array order and element values + * must survive the text round-trip. + */ +PG_PROP(array_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData a, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&a); + build_jsonb_array(tc, &a); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT (%s)::text::jsonb = (%s)", a.data, a.data); + + require_true(tc, conn, q.data); + + pfree(a.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Scalar round-trips: to_jsonb()::text::jsonb = to_jsonb(), and the + * literal scalars 'true' and 'null' round-trip through text too. The integer + * argument is parenthesized so a negative value binds to to_jsonb, not to the + * surrounding cast. + */ +PG_PROP(scalar_roundtrips) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData q; + int64 n = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT to_jsonb((" INT64_FORMAT "))::text::jsonb " + "= to_jsonb((" INT64_FORMAT ")) " + "AND 'true'::jsonb::text::jsonb = 'true'::jsonb " + "AND 'null'::jsonb::text::jsonb = 'null'::jsonb", + n, n); + + require_true(tc, conn, q.data); + + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Double pretty: jsonb_pretty(jsonb_pretty(J)::jsonb) = jsonb_pretty(J). + * Pretty-printing then reparsing then pretty-printing again must be stable -- + * pretty output is a canonical rendering of the same value. + */ +PG_PROP(double_pretty) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + build_jsonb_object(tc, &j); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT jsonb_pretty(jsonb_pretty(%s)::jsonb) " + "= jsonb_pretty(%s)", + j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * strip_nulls round-trip: jsonb_strip_nulls(J)::text::jsonb = + * jsonb_strip_nulls(J). Whatever structure survives null-stripping must + * itself round-trip through text unchanged. + */ +PG_PROP(strip_nulls_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData j, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&j); + build_jsonb_object(tc, &j); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT jsonb_strip_nulls(%s)::text::jsonb " + "= jsonb_strip_nulls(%s)", + j.data, j.data); + + require_true(tc, conn, q.data); + + pfree(j.data); + pfree(q.data); + pg_prop_disconnect(conn); +} diff --git a/src/test/prop/prop_numeric.c b/src/test/prop/prop_numeric.c new file mode 100644 index 00000000000..6a3b810be01 --- /dev/null +++ b/src/test/prop/prop_numeric.c @@ -0,0 +1,326 @@ +/*------------------------------------------------------------------------- + * + * prop_numeric.c + * Property-based tests for arbitrary-precision NUMERIC arithmetic. + * + * Target #9 in the design. The 2024 numeric multiplication/division rewrite + * (ca481d3c9ab, c4e44224cf6, 8dc28d7eb86, 9428c001f67) reworked carry + * handling in mul_var, mul_var_short and div_var -- the base-NBASE^2 + * accumulation path and the "short" multiply paths that handle 1..6 NBASE + * digits. Carry/length bugs in those routines show up as broken algebraic + * identities, so these properties check exact identities (commutativity, + * distributivity, the a = q*b + r division identity, additive inverse) in SQL + * against the numeric type itself, plus a sound bound on sqrt. + * + * The oracle here is the type's own algebra: numeric add/subtract/multiply + * and trunc-division (div/mod) are exact at the operands' combined scale, so + * these identities must hold with no tolerance. Any "f" result is a bug in + * the arithmetic, not a rounding artefact. + * + * Tier B: this runs SQL against the cluster the TAP driver started; see + * pg_prop.h and t/002_sql_props.pl. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_numeric.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "numeric" +#include "pg_prop.h" + +#include "lib/stringinfo.h" + +/* + * Draw a decimal NUMERIC literal as text into buf, parenthesized so a leading + * sign never misparses when the literal is substituted into a larger + * expression. + * + * Digit counts straddle the boundaries the mul rewrite cares about: one NBASE + * digit is 4 decimal digits, and mul_var_short handles 1..6 NBASE digits + * (~24 integer decimal digits), so the integer part is drawn up to 24 digits + * to cross both the NBASE and NBASE^2 length thresholds. Products of two + * 24-digit values are ~48 digits, far below the numeric limit (10^131072), so + * there is no overflow to gate on. + */ +static void +draw_numeric(pg_prop_tc *tc, StringInfo buf) +{ + int nint = (int) pg_prop_draw_int(tc, 0, 24); + int nfrac = (int) pg_prop_draw_int(tc, 0, 8); + bool neg = pg_prop_draw_bool(tc); + int ndigits = 0; + + appendStringInfoString(buf, "("); + if (neg) + appendStringInfoChar(buf, '-'); + + /* integer part */ + for (int i = 0; i < nint; i++) + { + appendStringInfoChar(buf, (char) ('0' + pg_prop_draw_int(tc, 0, 9))); + ndigits++; + } + + /* fractional part */ + if (nfrac > 0) + { + appendStringInfoChar(buf, '.'); + for (int i = 0; i < nfrac; i++) + { + appendStringInfoChar(buf, (char) ('0' + pg_prop_draw_int(tc, 0, 9))); + ndigits++; + } + } + + /* ensure a valid numeric: at least one digit */ + if (ndigits == 0) + appendStringInfoChar(buf, '0'); + + appendStringInfoString(buf, ")::numeric"); +} + +/* + * Draw a NUMERIC literal forced non-negative (positive sign), for sqrt(). + */ +static void +draw_numeric_nonneg(pg_prop_tc *tc, StringInfo buf) +{ + int nint = (int) pg_prop_draw_int(tc, 0, 24); + int nfrac = (int) pg_prop_draw_int(tc, 0, 8); + int ndigits = 0; + + appendStringInfoString(buf, "("); + for (int i = 0; i < nint; i++) + { + appendStringInfoChar(buf, (char) ('0' + pg_prop_draw_int(tc, 0, 9))); + ndigits++; + } + if (nfrac > 0) + { + appendStringInfoChar(buf, '.'); + for (int i = 0; i < nfrac; i++) + { + appendStringInfoChar(buf, (char) ('0' + pg_prop_draw_int(tc, 0, 9))); + ndigits++; + } + } + if (ndigits == 0) + appendStringInfoChar(buf, '0'); + appendStringInfoString(buf, ")::numeric"); +} + +/* + * Run a boolean-valued SELECT and require it to return "t". A NULL result or + * a SQL error means the arithmetic errored or produced NULL on finite inputs, + * which is itself a bug -- so either is a failure, reported with the SQL. + */ +static void +require_true(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql) +{ + bool failed = false; + char *err = NULL; + char *res; + + res = pg_prop_sql_scalar(conn, sql, &failed, &err); + if (failed) + { + pg_prop_fail(tc, "SQL error for: %s\n error: %s", + sql, err ? err : "(unknown)"); + } + if (res == NULL) + { + pg_prop_fail(tc, "unexpected NULL result for: %s", sql); + } + if (strcmp(res, "t") != 0) + { + pg_prop_fail(tc, "invariant failed (got %s) for: %s", res, sql); + } + pg_free(res); + if (err) + pg_free(err); +} + +/* + * Multiplication commutes: X * Y = Y * X. The base-NBASE^2 rewrite reorders + * accumulation, so a carry bug would surface as asymmetry here. + */ +PG_PROP(mul_commutes) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData x, + y, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&x); + initStringInfo(&y); + draw_numeric(tc, &x); + draw_numeric(tc, &y); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT (%s * %s) = (%s * %s)", + x.data, y.data, y.data, x.data); + + require_true(tc, conn, q.data); + + pfree(x.data); + pfree(y.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * The fundamental trunc-division identity: div(X, Y) * Y + mod(X, Y) = X. + * div() is integer/trunc division for numeric and mod() its matching + * remainder; this directly exercises div_var. The CASE guards Y = 0 so no + * division-by-zero error is expected -- if one still occurs, require_true + * fails on it. + */ +PG_PROP(div_mod_identity) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData x, + y, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&x); + initStringInfo(&y); + draw_numeric(tc, &x); + draw_numeric(tc, &y); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT CASE WHEN %s = 0 THEN true " + "ELSE div(%s, %s) * %s + mod(%s, %s) = %s END", + y.data, + x.data, y.data, y.data, x.data, y.data, x.data); + + require_true(tc, conn, q.data); + + pfree(x.data); + pfree(y.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Additive inverse: (X + Y) - Y = X. Numeric add/subtract are exact at the + * operands' scale, so this holds with no rounding. + */ +PG_PROP(add_sub_inverse) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData x, + y, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&x); + initStringInfo(&y); + draw_numeric(tc, &x); + draw_numeric(tc, &y); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ((%s + %s) - %s) = %s", + x.data, y.data, y.data, x.data); + + require_true(tc, conn, q.data); + + pfree(x.data); + pfree(y.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Distributivity: X * (Y + Z) = X*Y + X*Z. A carry/length bug in mul_var + * often breaks this when one operand crosses a digit-count boundary while the + * other does not, so the two sides accumulate carries differently. + */ +PG_PROP(mul_distributes_over_add) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData x, + y, + z, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&x); + initStringInfo(&y); + initStringInfo(&z); + draw_numeric(tc, &x); + draw_numeric(tc, &y); + draw_numeric(tc, &z); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT (%s * (%s + %s)) = (%s * %s + %s * %s)", + x.data, y.data, z.data, + x.data, y.data, x.data, z.data); + + require_true(tc, conn, q.data); + + pfree(x.data); + pfree(y.data); + pfree(z.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * sqrt bound: S = sqrt(abs(X)) is non-negative and, since numeric sqrt rounds + * to a scale, S*S must not exceed abs(X) by more than a small relative-plus- + * absolute tolerance. This is a sound one-sided bound: an exact sqrt gives + * S*S = X, and the only slack is the rounding of the last digit, well within + * abs(X) * 1e-10 + 1e-10. (The symmetric lower bound is scale-dependent and + * fiddly to state soundly, so we assert only the clean upper side plus the + * sign.) + */ +PG_PROP(sqrt_bounds) +{ + pg_prop_conn *conn; + char *err = NULL; + StringInfoData x, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + initStringInfo(&x); + draw_numeric_nonneg(tc, &x); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT sqrt(abs(%s)) >= 0 " + "AND sqrt(abs(%s)) * sqrt(abs(%s)) " + "<= abs(%s) * (1 + 1e-10::numeric) + 1e-10::numeric", + x.data, x.data, x.data, x.data); + + require_true(tc, conn, q.data); + + pfree(x.data); + pfree(q.data); + pg_prop_disconnect(conn); +} diff --git a/src/test/prop/prop_prng.c b/src/test/prop/prop_prng.c new file mode 100644 index 00000000000..647c3aab822 --- /dev/null +++ b/src/test/prop/prop_prng.c @@ -0,0 +1,132 @@ +/*------------------------------------------------------------------------- + * + * prop_prng.c + * Property-based tests for src/common/pg_prng.c. + * + * pg_prng is the shared pseudo-random generator (xoroshiro128**) behind + * random(), sampling, and backend jitter. Its range helpers carry explicit + * contracts -- a value in [rmin, rmax], a double in [0, 1) -- that must hold + * for every seed and every bound, including the degenerate rmin == rmax case + * and the full-width range. Those contracts are exactly what a property test + * pins down. + * + * Tier A: in-process, no server needed. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_prng.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "prng" +#include "pg_prop.h" + +#include "common/pg_prng.h" + +/* + * Seed a fresh generator from a drawn 64-bit value. Seeding the PRNG from a + * hegel-drawn integer is the right pattern here (unlike feeding a seed to code + * that then makes its own random decisions): the property is about the range + * helpers' output bounds, which must hold for every internal state, so the + * seed is itself the input we want hegel to explore and shrink. + */ +static void +seed_from_draw(pg_prop_tc *tc, pg_prng_state *state) +{ + uint64 s = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + + pg_prng_seed(state, s); +} + +PG_PROP(uint64_range_within_bounds) +{ + pg_prng_state state; + uint64 lo = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + uint64 hi = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + uint64 r; + + if (lo > hi) + { + uint64 tmp = lo; + + lo = hi; + hi = tmp; + } + + seed_from_draw(tc, &state); + + /* draw several values so one seed exercises more of the stream */ + for (int i = 0; i < 20; i++) + { + r = pg_prng_uint64_range(&state, lo, hi); + if (r < lo || r > hi) + pg_prop_fail(tc, + "uint64_range out of bounds: lo=%llu hi=%llu got=%llu", + (unsigned long long) lo, (unsigned long long) hi, + (unsigned long long) r); + } +} + +PG_PROP(int64_range_within_bounds) +{ + pg_prng_state state; + int64 lo = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + int64 hi = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + int64 r; + + if (lo > hi) + { + int64 tmp = lo; + + lo = hi; + hi = tmp; + } + + seed_from_draw(tc, &state); + + for (int i = 0; i < 20; i++) + { + r = pg_prng_int64_range(&state, lo, hi); + if (r < lo || r > hi) + pg_prop_fail(tc, + "int64_range out of bounds: lo=%lld hi=%lld got=%lld", + (long long) lo, (long long) hi, (long long) r); + } +} + +PG_PROP(singleton_range_is_exact) +{ + pg_prng_state state; + int64 v = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX); + + seed_from_draw(tc, &state); + + /* a range of one value must always return that value */ + for (int i = 0; i < 8; i++) + { + int64 r = pg_prng_int64_range(&state, v, v); + + if (r != v) + pg_prop_fail(tc, "singleton range [%lld,%lld] returned %lld", + (long long) v, (long long) v, (long long) r); + } +} + +PG_PROP(double_in_unit_interval) +{ + pg_prng_state state; + + seed_from_draw(tc, &state); + + /* pg_prng_double() is documented to return a value in [0.0, 1.0) */ + for (int i = 0; i < 50; i++) + { + double d = pg_prng_double(&state); + + if (!(d >= 0.0 && d < 1.0)) + pg_prop_fail(tc, "double not in [0,1): got %.17g", d); + } +} diff --git a/src/test/prop/prop_uuid.c b/src/test/prop/prop_uuid.c new file mode 100644 index 00000000000..943bd7be175 --- /dev/null +++ b/src/test/prop/prop_uuid.c @@ -0,0 +1,304 @@ +/*------------------------------------------------------------------------- + * + * prop_uuid.c + * Property-based tests for the uuid type. + * + * uuid in/out parsing (dashes, braces, case) and ordering have had fixes; + * these check that parsing is tolerant and canonicalizing, and that + * comparison is a total order consistent with the byte string. + * + * Tier B: this runs SQL against the cluster the TAP driver started; see + * pg_prop.h and t/002_sql_props.pl. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/prop/prop_uuid.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#define PG_PROP_AREA "uuid" +#include "pg_prop.h" + +#include "lib/stringinfo.h" + +/* + * Draw 16 random bytes and render them as a 32-character lowercase hex string + * (no dashes) into "hex", a caller-provided buffer of at least 33 bytes. Each + * byte is drawn with pg_prop_draw_int(0, 255) and printed "%02x", so the + * result is always fixed-width and lowercase. + */ +static void +draw_uuid_hex(pg_prop_tc *tc, char *hex) +{ + int i; + + for (i = 0; i < 16; i++) + { + int b = (int) pg_prop_draw_int(tc, 0, 255); + + snprintf(hex + i * 2, 3, "%02x", b); + } + hex[32] = '\0'; +} + +/* + * Insert dashes into a 32-char hex string to form the canonical 8-4-4-4-12 + * dashed UUID text, appended to buf. Dashes go after hex positions 8, 12, 16 + * and 20. + */ +static void +append_dashed(StringInfo buf, const char *hex) +{ + appendBinaryStringInfo(buf, hex, 8); + appendStringInfoChar(buf, '-'); + appendBinaryStringInfo(buf, hex + 8, 4); + appendStringInfoChar(buf, '-'); + appendBinaryStringInfo(buf, hex + 12, 4); + appendStringInfoChar(buf, '-'); + appendBinaryStringInfo(buf, hex + 16, 4); + appendStringInfoChar(buf, '-'); + appendBinaryStringInfo(buf, hex + 20, 12); +} + +/* + * Run a boolean-valued SELECT and require it to return "t". A NULL result or + * a SQL error means a validly-generated value parsed to NULL or errored, which + * is itself a bug -- so either is a failure, reported with the SQL. + */ +static void +require_true(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql) +{ + bool failed = false; + char *err = NULL; + char *res; + + res = pg_prop_sql_scalar(conn, sql, &failed, &err); + if (failed) + { + pg_prop_fail(tc, "SQL error for: %s\n error: %s", + sql, err ? err : "(unknown)"); + } + if (res == NULL) + { + pg_prop_fail(tc, "unexpected NULL result for: %s", sql); + } + if (strcmp(res, "t") != 0) + { + pg_prop_fail(tc, "invariant failed (got %s) for: %s", res, sql); + } + pg_free(res); + if (err) + pg_free(err); +} + +/* + * Text round-trip: parsing a canonical dashed uuid and printing it yields the + * lowercase canonical form. Since the drawn literal U is already lowercase + * canonical, (U::uuid)::text must equal lower(U). This exercises uuid_in then + * uuid_out. + */ +PG_PROP(text_roundtrip) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex[33]; + StringInfoData d, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex); + initStringInfo(&d); + append_dashed(&d, hex); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ('%s'::uuid)::text = lower('%s')", + d.data, d.data); + + require_true(tc, conn, q.data); + + pfree(d.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Dash-insensitive parse: uuid_in accepts both the 32-hex form without dashes + * and the standard dashed form, and they denote the same value. So H::uuid = + * D::uuid where H and D come from the same bytes. + */ +PG_PROP(dash_insensitive_parse) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex[33]; + StringInfoData d, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex); + initStringInfo(&d); + append_dashed(&d, hex); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ('%s'::uuid) = ('%s'::uuid)", + hex, d.data); + + require_true(tc, conn, q.data); + + pfree(d.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Case-insensitive parse: uuid_in treats the hex digits case-insensitively, so + * the lowercase dashed form and its uppercase version denote the same value. + * upper() produces the uppercase form in SQL so the C side keeps one encoding. + */ +PG_PROP(case_insensitive_parse) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex[33]; + StringInfoData d, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex); + initStringInfo(&d); + append_dashed(&d, hex); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ('%s'::uuid) = (upper('%s')::uuid)", + d.data, d.data); + + require_true(tc, conn, q.data); + + pfree(d.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Brace-form parse: uuid_in accepts the {dashed} braced form and it denotes + * the same value as the bare dashed form. + */ +PG_PROP(brace_form_parse) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex[33]; + StringInfoData d, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex); + initStringInfo(&d); + append_dashed(&d, hex); + + initStringInfo(&q); + appendStringInfo(&q, "SELECT ('%s'::uuid) = ('{%s}'::uuid)", + d.data, d.data); + + require_true(tc, conn, q.data); + + pfree(d.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Total order: for two independently drawn uuids exactly one of <, =, > holds, + * so the sum of those three booleans cast to int is 1. This checks uuid + * comparison is a total order (trichotomy). + */ +PG_PROP(comparison_total_order) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex1[33], + hex2[33]; + StringInfoData d1, + d2, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex1); + draw_uuid_hex(tc, hex2); + initStringInfo(&d1); + initStringInfo(&d2); + append_dashed(&d1, hex1); + append_dashed(&d2, hex2); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT (('%s'::uuid < '%s'::uuid)::int " + "+ ('%s'::uuid = '%s'::uuid)::int " + "+ ('%s'::uuid > '%s'::uuid)::int) = 1", + d1.data, d2.data, d1.data, d2.data, d1.data, d2.data); + + require_true(tc, conn, q.data); + + pfree(d1.data); + pfree(d2.data); + pfree(q.data); + pg_prop_disconnect(conn); +} + +/* + * Order matches the hex string: uuid comparison must agree with the + * lexicographic comparison of the canonical lowercase 32-char hex (no dashes). + * Both hex strings are lowercase fixed-width, so SQL text "<" is the right + * oracle, and the dashed and raw forms are built from the same bytes. + */ +PG_PROP(order_matches_hex_string) +{ + pg_prop_conn *conn; + char *err = NULL; + char hex1[33], + hex2[33]; + StringInfoData d1, + d2, + q; + + conn = pg_prop_connect(&err); + if (conn == NULL) + pg_prop_fail(tc, "connect failed: %s", err ? err : ""); + + draw_uuid_hex(tc, hex1); + draw_uuid_hex(tc, hex2); + initStringInfo(&d1); + initStringInfo(&d2); + append_dashed(&d1, hex1); + append_dashed(&d2, hex2); + + initStringInfo(&q); + appendStringInfo(&q, + "SELECT ('%s'::uuid < '%s'::uuid) = ('%s' < '%s')", + d1.data, d2.data, hex1, hex2); + + require_true(tc, conn, q.data); + + pfree(d1.data); + pfree(d2.data); + pfree(q.data); + pg_prop_disconnect(conn); +} diff --git a/src/test/prop/t/001_prop.pl b/src/test/prop/t/001_prop.pl new file mode 100644 index 00000000000..e492bb3e03d --- /dev/null +++ b/src/test/prop/t/001_prop.pl @@ -0,0 +1,93 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# TAP driver for the property-based tests. Runs each area of pg_prop and +# reports one test point per area; pg_prop's own per-property output is echoed +# as TAP diagnostics so a failure shows the counterexample and replay blob. +# +# Controls (all optional) come from the environment and are forwarded, so one +# `meson test` run is reproducible and a buildfarm failure can be replayed: +# PG_PROP_ITERATIONS test cases per property (default: pg_prop's own 100) +# PG_PROP_SEED fixed RNG seed (default: pg_prop picks and prints one) +# PG_PROP_TESTS whitespace-separated property names to run +# +# To debug a single property, run pg_prop directly, e.g. +# pg_prop path canonicalize_idempotent --iterations 100000 --seed 42 +# See README and DESIGN. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Utils; +use Test::More; + +# TAP driver for the in-process (Tier A) property tests. These exercise +# src/common and src/port code linked directly into pg_prop, so they need no +# cluster. The SQL-oracle (Tier B) areas run from t/002_sql_props.pl instead. +# +# Keep in step with the Tier-A entries in prop_area_sources (meson.build). +my @areas = qw(base64 bytea uuid numeric intervalio jsonbroundtrip prng); + +# No Tier-A areas wired in yet (e.g. the initial harness-only commit): nothing +# to run, which is a pass, not a failure. +if (!@areas) +{ + plan skip_all => 'no in-process property areas registered'; +} + +# Shared arguments from the environment. +my @common_args = ('--no-tap'); +push @common_args, ('--iterations', $ENV{PG_PROP_ITERATIONS}) + if defined $ENV{PG_PROP_ITERATIONS} && $ENV{PG_PROP_ITERATIONS} ne ''; +push @common_args, ('--seed', $ENV{PG_PROP_SEED}) + if defined $ENV{PG_PROP_SEED} && $ENV{PG_PROP_SEED} ne ''; + +# PG_PROP_TESTS selects individual properties by name (unqualified). +my @select; +@select = split /\s+/, $ENV{PG_PROP_TESTS} + if defined $ENV{PG_PROP_TESTS} && $ENV{PG_PROP_TESTS} ne ''; + +# All areas are known to one binary; list once. +my $list = ''; +run_log([ 'pg_prop', '--list' ], '>', \$list); +my %known_in_area; +for my $qualified (split /\n/, $list) +{ + my ($a, $n) = split m{/}, $qualified, 2; + push @{ $known_in_area{$a} }, $n if defined $n; +} + +foreach my $area (@areas) +{ + my @args = @common_args; + if (@select) + { + my %have = map { $_ => 1 } @{ $known_in_area{$area} // [] }; + my @mine = grep { $have{$_} } @select; + # A selection that this area has none of: skip it with a passing TODO + # so the plan still accounts for every area. + SKIP: + { + skip "no selected properties in area $area", 1 unless @mine; + run_area($area, [ @args, @mine ]); + } + next; + } + run_area($area, \@args); +} + +done_testing(); + +# Run one area of pg_prop; emit one test point plus diagnostics. +sub run_area +{ + my ($area, $args) = @_; + my ($stdout, $stderr) = ('', ''); + my $ok = run_log([ 'pg_prop', $area, @$args ], + '>', \$stdout, '2>', \$stderr); + + # Echo pg_prop's plain output as TAP diagnostics (note() prefixes "# "). + note($stdout) if $stdout ne ''; + note($stderr) if $stderr ne ''; + + ok($ok, "pg_prop $area: all properties held"); +} -- 2.54.0