diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c90099d..590b3df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: run: pnpm run build working-directory: ${{ matrix.package.path }} + - name: Native parity 🔍 + run: node scripts/native-parity.js ${{ matrix.package.path }} + - name: Upload WASM Artifacts 📦 uses: actions/upload-artifact@v4 with: diff --git a/scripts/native-parity.js b/scripts/native-parity.js new file mode 100644 index 0000000..da4b31e --- /dev/null +++ b/scripts/native-parity.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Checks that a built versions//wasm parses exactly like libpg_query compiled natively + * from the same commit, over libpg_query's test SQL: test/sql/postgres_regress and + * plpgsql_regress (the PostgreSQL regression suite) plus its own deparse tests. + * + * node scripts/native-parity.js versions/18 (after `pnpm build` in that directory) + * + * Reuses the libpg_query checkout the WASM build cloned into .cache, so it needs no network. + */ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const glob = require('glob'); +const os = require('os'); +const path = require('path'); + +// FETCH_ALL is LONG_MAX, which is 32-bit on wasm32 +const FETCH_ALL_NATIVE = 9223372036854775807; +const FETCH_ALL_WASM = 2147483647; + +async function main() { + const versionDir = path.resolve(process.argv[2] || '.'); + const commit = require(path.join(versionDir, 'package.json'))['x-publish'].libpgQueryCommit; + const [source] = glob.sync(`.cache/*/libpg_query/${commit}`, { cwd: versionDir, absolute: true }); + if (!source) throw new Error(`no libpg_query checkout for ${commit} under ${versionDir}/.cache; run pnpm build first`); + + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'libpg-query-native-')); + try { + await checkParity(versionDir, source, commit, work); + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +} + +async function checkParity(versionDir, source, commit, work) { + execFileSync('sh', ['-c', `git -C "${source}" archive ${commit} | tar -x -C "${work}"`]); + // glibc >= 2.38 declares strchrnul, which PG <= 14's vendored snprintf.c would otherwise redefine + const cppflags = process.platform === 'linux' ? 'CPPFLAGS=-DHAVE_STRCHRNUL=' : 'CPPFLAGS='; + execFileSync('make', ['-j', String(os.cpus().length), cppflags, 'build'], { cwd: work, stdio: ['ignore', 'ignore', 'inherit'] }); + const driver = path.join(work, 'parity-driver'); + execFileSync('cc', ['-O2', '-I', work, path.join(__dirname, 'native-parity/driver.c'), path.join(work, 'libpg_query.a'), '-lpthread', '-o', driver]); + + const files = glob.sync('test/{sql/**/*.{sql,psql},plpgsql_samples.sql}', { cwd: work }).sort(); + const statements = []; + for (const file of files) { + const split = JSON.parse(execFileSync(driver, ['split', path.join(work, file)], { maxBuffer: 1 << 28 }).toString()); + if (split.error) continue; // e.g. strings.sql: intentionally invalid escapes the scanner rejects + split.stmts.forEach((sql, i) => statements.push({ key: `${file}#${i}`, sql })); + } + + const input = path.join(work, 'statements.bin'); + fs.writeFileSync(input, Buffer.concat(statements.map(({ sql }) => Buffer.from(sql + '\0')))); + const output = path.join(work, 'native.jsonl'); + const fd = fs.openSync(output, 'w'); + execFileSync(driver, ['parse', input], { stdio: ['ignore', fd, 'inherit'] }); + fs.closeSync(fd); + const native = fs.readFileSync(output, 'utf8').trimEnd().split('\n'); + + const wasm = require(path.join(versionDir, 'wasm/index.cjs')); + await wasm.loadModule(); + + const diffs = []; + statements.forEach(({ key, sql }, i) => { + const expected = JSON.parse(native[i]); + let actual; + try { + actual = { parse: wasm.parseSync(sql) }; + } catch (err) { + actual = { error: { message: err.message, cursorpos: err.sqlDetails ? err.sqlDetails.cursorPosition + 1 : undefined } }; + } + if (expected.error && actual.error && expected.error.cursorpos === 0) expected.error.cursorpos = 1; + const normalize = (value) => + JSON.stringify(value, (k, v) => ((k === 'howMany' || k === 'how_many') && v === FETCH_ALL_NATIVE ? FETCH_ALL_WASM : v)); + const a = normalize(expected); + const b = JSON.stringify(actual); + if (a !== b) diffs.push({ key, native: a.slice(0, 300), wasm: b.slice(0, 300) }); + }); + + console.log(`native parity: ${statements.length} statements from ${files.length} files @ ${commit.slice(0, 7)}, ${diffs.length} differences`); + for (const d of diffs.slice(0, 10)) console.log(JSON.stringify(d, null, 2)); + if (diffs.length) process.exitCode = 1; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/native-parity/driver.c b/scripts/native-parity/driver.c new file mode 100644 index 0000000..95f9583 --- /dev/null +++ b/scripts/native-parity/driver.c @@ -0,0 +1,95 @@ +/* + * Native side of scripts/native-parity.js. + * driver split -> {"stmts":["...", ...]} or {"error":"..."} + * driver parse -> one JSON line per NUL-separated statement: + * {"parse":} or {"error":{"message":"...","cursorpos":N}} + */ +#include +#include +#include +#include "pg_query.h" + +static void print_json_string(const char *s) { + putchar('"'); + for (const unsigned char *p = (const unsigned char *) s; p && *p; p++) { + if (*p == '"' || *p == '\\') { + putchar('\\'); + putchar(*p); + } else if (*p < 0x20) { + printf("\\u%04x", *p); + } else { + putchar(*p); + } + } + putchar('"'); +} + +static char *read_file(const char *path, size_t *len) { + FILE *f = fopen(path, "rb"); + if (!f) { + perror(path); + exit(2); + } + fseek(f, 0, SEEK_END); + long n = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = malloc(n + 1); + if (fread(buf, 1, n, f) != (size_t) n) { + perror(path); + exit(2); + } + buf[n] = 0; + fclose(f); + *len = n; + return buf; +} + +int main(int argc, char **argv) { + size_t len; + if (argc != 3) { + fprintf(stderr, "usage: driver split|parse \n"); + return 1; + } + char *buf = read_file(argv[2], &len); + + if (strcmp(argv[1], "split") == 0) { + PgQuerySplitResult r = pg_query_split_with_scanner(buf); + if (r.error) { + printf("{\"error\":"); + print_json_string(r.error->message); + printf("}\n"); + } else { + printf("{\"stmts\":["); + for (int i = 0; i < r.n_stmts; i++) { + int n = r.stmts[i]->stmt_len; + char *stmt = malloc(n + 1); + memcpy(stmt, buf + r.stmts[i]->stmt_location, n); + stmt[n] = 0; + if (i) putchar(','); + print_json_string(stmt); + free(stmt); + } + printf("]}\n"); + } + pg_query_free_split_result(r); + return 0; + } + + if (strcmp(argv[1], "parse") == 0) { + for (char *p = buf; p < buf + len; p += strlen(p) + 1) { + PgQueryParseResult r = pg_query_parse(p); + if (r.error) { + printf("{\"error\":{\"message\":"); + print_json_string(r.error->message); + printf(",\"cursorpos\":%d}}\n", r.error->cursorpos); + } else { + printf("{\"parse\":%s}\n", r.parse_tree); + } + pg_query_free_parse_result(r); + } + return 0; + } + + fprintf(stderr, "unknown mode %s\n", argv[1]); + return 1; +}