Repository navigation
test: round-trip the PostgreSQL regression corpus with a known-failures ratchet #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| [ | ||
| "plpgsql_samples-28.sql", | ||
| "plpgsql_samples-31.sql", | ||
| "plpgsql_samples-33.sql", | ||
| "plpgsql_samples-34.sql", | ||
| "plpgsql_samples-36.sql", | ||
| "postgres_regress/case-43.sql", | ||
| "postgres_regress/case-46.sql", | ||
| "postgres_regress/case-53.sql", | ||
| "postgres_regress/case-54.sql", | ||
| "postgres_regress/copy2-2.sql", | ||
| "postgres_regress/copy2-3.sql", | ||
| "postgres_regress/create_index-535.sql", | ||
| "postgres_regress/create_index-536.sql", | ||
| "postgres_regress/create_operator-17.sql", | ||
| "postgres_regress/event_trigger-79.sql", | ||
| "postgres_regress/explain-46.sql", | ||
| "postgres_regress/plpgsql-22.sql", | ||
| "postgres_regress/plpgsql-24.sql", | ||
| "postgres_regress/plpgsql-28.sql", | ||
| "postgres_regress/plpgsql-30.sql", | ||
| "postgres_regress/plpgsql-34.sql", | ||
| "postgres_regress/plpgsql-38.sql", | ||
| "postgres_regress/plpgsql-40.sql", | ||
| "postgres_regress/plpgsql-44.sql", | ||
| "postgres_regress/plpgsql-46.sql", | ||
| "postgres_regress/plpgsql-48.sql", | ||
| "postgres_regress/plpgsql-54.sql", | ||
| "postgres_regress/plpgsql-60.sql", | ||
| "postgres_regress/plpgsql-64.sql", | ||
| "postgres_regress/plpgsql-66.sql", | ||
| "postgres_regress/plpgsql-68.sql", | ||
| "postgres_regress/plpgsql-70.sql", | ||
| "postgres_regress/plpgsql-72.sql", | ||
| "postgres_regress/plpgsql-74.sql", | ||
| "postgres_regress/plpgsql-76.sql", | ||
| "postgres_regress/plpgsql-80.sql", | ||
| "postgres_regress/plpgsql-81.sql", | ||
| "postgres_regress/plpgsql-82.sql", | ||
| "postgres_regress/plpgsql-88.sql", | ||
| "postgres_regress/plpgsql-89.sql", | ||
| "postgres_regress/plpgsql-90.sql", | ||
| "postgres_regress/plpgsql-91.sql", | ||
| "postgres_regress/plpgsql-92.sql", | ||
| "postgres_regress/plpgsql-283.sql", | ||
| "postgres_regress/plpgsql-286.sql", | ||
| "postgres_regress/plpgsql-289.sql", | ||
| "postgres_regress/plpgsql-291.sql", | ||
| "postgres_regress/plpgsql-293.sql", | ||
| "postgres_regress/plpgsql-295.sql", | ||
| "postgres_regress/plpgsql-298.sql", | ||
| "postgres_regress/plpgsql-364.sql", | ||
| "postgres_regress/plpgsql-365.sql", | ||
| "postgres_regress/plpgsql-387.sql", | ||
| "postgres_regress/plpgsql-424.sql", | ||
| "postgres_regress/plpgsql-504.sql", | ||
| "postgres_regress/plpgsql-768.sql", | ||
| "postgres_regress/plpgsql-854.sql", | ||
| "postgres_regress/plpgsql-857.sql", | ||
| "postgres_regress/plpgsql-875.sql", | ||
| "postgres_regress/plpgsql-880.sql", | ||
| "postgres_regress/privileges-766.sql", | ||
| "postgres_regress/rangefuncs-128.sql", | ||
| "postgres_regress/rangefuncs-137.sql", | ||
| "postgres_regress/rangefuncs-168.sql", | ||
| "postgres_regress/rowsecurity-23.sql", | ||
| "postgres_regress/rowsecurity-1068.sql", | ||
| "postgres_regress/select_views-5.sql", | ||
| "postgres_regress/stats_ext-741.sql", | ||
| "postgres_regress/temp-49.sql", | ||
| "postgres_regress/transactions-239.sql", | ||
| "postgres_regress/transactions-244.sql", | ||
| "postgres_regress/triggers-261.sql" | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Round-trips the PostgreSQL regression corpus (__fixtures__/regress/statements.json.gz, | ||
| * built by `npm run fixtures:regress`) through parse -> deparse -> parse, pretty and not. | ||
| * | ||
| * known-failures.deparser.json is a ratchet: a new failure fails the test, and so does a listed | ||
| * statement that now passes. After a deliberate change, refresh it with | ||
| * UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress | ||
| */ | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import * as zlib from 'zlib'; | ||
|
|
||
| import { TestUtils } from '../../test-utils'; | ||
|
|
||
| const FIXTURE_DIR = path.join(__dirname, '../../../../__fixtures__/regress'); | ||
| const KNOWN_FAILURES_FILE = path.join(FIXTURE_DIR, 'known-failures.deparser.json'); | ||
| const UPDATE = process.env.UPDATE_KNOWN_FAILURES === '1'; | ||
|
|
||
| const { statements } = JSON.parse( | ||
| zlib.gunzipSync(fs.readFileSync(path.join(FIXTURE_DIR, 'statements.json.gz'))).toString('utf8') | ||
| ) as { statements: Record<string, string> }; | ||
| const knownFailures: Record<string, string[]> = JSON.parse(fs.readFileSync(KNOWN_FAILURES_FILE, 'utf8')); | ||
|
|
||
| const byFile = new Map<string, string[]>(); | ||
| for (const key of Object.keys(statements)) { | ||
| const file = key.replace(/-\d+\.sql$/, '.sql'); | ||
| byFile.set(file, [...(byFile.get(file) ?? []), key]); | ||
| } | ||
|
|
||
| const observed: Record<string, string[]> = {}; | ||
| const ran = new Set<string>(); | ||
| const utils = new TestUtils(); | ||
|
|
||
| describe('regress corpus round-trip', () => { | ||
| beforeAll(() => { | ||
| for (const level of ['error', 'warn', 'log'] as const) jest.spyOn(console, level).mockImplementation(() => {}); | ||
| }); | ||
| afterAll(() => { | ||
| jest.restoreAllMocks(); | ||
| if (UPDATE) { | ||
| // Only replace entries for files that ran, so a filtered run (-t, --shard) keeps the rest | ||
| const merged = { ...knownFailures }; | ||
| for (const file of ran) delete merged[file]; | ||
| Object.assign(merged, observed); | ||
| for (const file of Object.keys(merged)) if (!byFile.has(file)) delete merged[file]; | ||
| const sorted = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))); | ||
| fs.writeFileSync(KNOWN_FAILURES_FILE, JSON.stringify(sorted, null, 2) + '\n'); | ||
| } | ||
| }); | ||
|
|
||
| it('lists only files that are in the corpus', () => { | ||
| expect(Object.keys(knownFailures).filter((file) => !byFile.has(file))).toEqual([]); | ||
| }); | ||
|
|
||
| it.each([...byFile.keys()])('%s', async (file) => { | ||
| const failing: string[] = []; | ||
| for (const key of byFile.get(file)!) { | ||
| try { | ||
| await utils.expectAstMatch(key, statements[key]); | ||
| } catch { | ||
| failing.push(key); | ||
| } | ||
| } | ||
| ran.add(file); | ||
| if (failing.length) observed[file] = failing; | ||
| if (!UPDATE) expect(failing).toEqual(knownFailures[file] ?? []); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| #!/usr/bin/env ts-node | ||
| /** | ||
| * Builds __fixtures__/regress/statements.json.gz from a libpg_query checkout: | ||
| * the PostgreSQL regression SQL it vendors (test/sql/postgres_regress, plpgsql_regress) | ||
| * plus its own test SQL (test/sql/deparse*, test/plpgsql_samples.sql). | ||
| * | ||
| * npm run fixtures:regress -- /path/to/libpg_query | ||
| * | ||
| * Check out the libpg_query commit that the pinned libpg-query release was built from | ||
| * (x-publish.libpgQueryCommit in libpg-query-node versions/18/package.json). | ||
| */ | ||
| import { execFileSync } from 'child_process'; | ||
| import * as fs from 'fs'; | ||
| import { sync as globSync } from 'glob'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bug · medium
📋 Prompt for AI AgentsIn packages/deparser/package.json, add a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The candidate diff for 'packages/deparser/package.json' only shows the addition of the 'fixtures:regress' script entry — it does not include a 'glob' entry under 'devDependencies'. The fix described (adding 'glob': '^11.0.0' to devDependencies) is not visible in any of the evidence provided. The user's claim that hoisting from the root covers this is unreliable under pnpm's isolated node_modules layout, and the other scripts cited as precedent have the same latent issue. The original finding remains unresolved. |
||
| import { loadModule, parseSync, scanSync } from 'libpg-query'; | ||
| import * as path from 'path'; | ||
| import * as zlib from 'zlib'; | ||
|
|
||
| import { generateStatementKey } from './statement-splitter'; | ||
|
|
||
| const OUT_FILE = path.join(__dirname, '../../../__fixtures__/regress/statements.json.gz'); | ||
| const SOURCES = [ | ||
| 'test/sql/postgres_regress/*.sql', | ||
| 'test/sql/plpgsql_regress/*.sql', | ||
| 'test/sql/deparse/*.sql', | ||
| 'test/sql/deparse-depesz/**/*.{sql,psql}', | ||
| 'test/plpgsql_samples.sql', | ||
| ]; | ||
|
|
||
| // Split on top-level semicolons using the real scanner, so string/dollar quoting and | ||
| // comments are handled. CREATE RULE (...; ...) and BEGIN ATOMIC ... END bodies stay whole. | ||
| function splitWithScanner(sql: string): string[] { | ||
| const buf = Buffer.from(sql, 'utf8'); | ||
| const tokens = scanSync(sql).tokens; | ||
| const out: string[] = []; | ||
| let start = 0; | ||
| let parens = 0; | ||
| let atomic = 0; | ||
| let cases = 0; | ||
| let prev = ''; | ||
| for (const tok of tokens) { | ||
| const word = tok.text.toUpperCase(); | ||
| if (tok.text === '(') parens++; | ||
| else if (tok.text === ')') parens = Math.max(0, parens - 1); | ||
| else if (word === 'ATOMIC' && prev === 'BEGIN') atomic++; | ||
| else if (atomic > 0 && word === 'CASE') cases++; | ||
| else if (atomic > 0 && word === 'END') { | ||
| if (cases > 0) cases--; | ||
| else atomic--; | ||
| } else if (tok.text === ';' && parens === 0 && atomic === 0) { | ||
|
tenki-reviewer[bot] marked this conversation as resolved.
|
||
| out.push(buf.subarray(start, tok.start).toString('utf8')); | ||
| start = tok.end; | ||
| } | ||
| if (tok.tokenName !== 'SQL_COMMENT' && tok.tokenName !== 'C_COMMENT') prev = word; | ||
| } | ||
| out.push(buf.subarray(start).toString('utf8')); | ||
| return out; | ||
| } | ||
|
|
||
| function splitStatements(sql: string): string[] { | ||
| let pieces: string[]; | ||
| try { | ||
| pieces = splitWithScanner(sql); | ||
| } catch { | ||
| // e.g. strings.sql has intentionally invalid escapes the scanner rejects | ||
| pieces = sql.split(/;[ \t]*\r?\n/); | ||
| } | ||
| return pieces | ||
| .map((s) => s.replace(/^(\s*--[^\n]*\n|\s+)+/, '').trim()) | ||
| .filter((s) => s.length > 0); | ||
|
Comment on lines
+64
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bug · medium Fallback regex split corrupts dollar-quoted strings When 📋 Prompt for AI AgentsIn packages/deparser/scripts/make-regress-fixtures.ts, in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback only runs for files the scanner rejects outright (just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback |
||
| } | ||
|
|
||
| async function main() { | ||
| const root = path.resolve(process.argv[2] || process.env.LIBPG_QUERY_DIR || ''); | ||
| if (!fs.existsSync(path.join(root, 'test/sql/postgres_regress'))) { | ||
| throw new Error('usage: npm run fixtures:regress -- /path/to/libpg_query'); | ||
| } | ||
| await loadModule(); | ||
| const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD']).toString().trim(); | ||
| const files = SOURCES.flatMap((pattern) => globSync(pattern, { cwd: root })).sort(); | ||
| const seen = new Set<string>(); | ||
| const statements: Record<string, string> = {}; | ||
| let skipped = 0; | ||
| for (const rel of files) { | ||
| const relKey = rel.replace(/^test\/(sql\/)?/, '').replace(/\.psql$/, '.sql'); | ||
| splitStatements(fs.readFileSync(path.join(root, rel), 'utf8')).forEach((sql, i) => { | ||
| if (seen.has(sql)) return; | ||
| seen.add(sql); | ||
| try { | ||
| if (!parseSync(sql).stmts?.length) return; | ||
| } catch { | ||
| skipped++; | ||
| return; | ||
| } | ||
| statements[generateStatementKey(relKey, i)] = sql; | ||
| }); | ||
| } | ||
| const json = JSON.stringify({ libpgQueryCommit: commit, statements }); | ||
| fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); | ||
| fs.writeFileSync(OUT_FILE, zlib.gzipSync(json, { level: 9 })); | ||
| console.log( | ||
| `${files.length} files, ${Object.keys(statements).length} statements (${skipped} unparseable skipped) ` + | ||
| `-> ${path.relative(process.cwd(), OUT_FILE)} (${fs.statSync(OUT_FILE).size} bytes)` | ||
| ); | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** | ||
| * Round-trips the PL/pgSQL functions and DO blocks of the PostgreSQL regression corpus | ||
| * (__fixtures__/regress/statements.json.gz, built by `npm run fixtures:regress` in | ||
| * packages/deparser) through parse -> deparse -> parse. | ||
| * | ||
| * known-failures.plpgsql-deparser.json is a ratchet: a new failure fails the test, and so | ||
| * does a listed statement that now passes. After a deliberate change, refresh it with | ||
| * UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress-corpus | ||
| */ | ||
| import * as fs from 'fs'; | ||
| import { loadModule, parsePlPgSQLSync } from 'libpg-query'; | ||
| import * as path from 'path'; | ||
| import * as zlib from 'zlib'; | ||
|
|
||
| import { PLpgSQLTestUtils } from '../test-utils'; | ||
|
|
||
| const FIXTURE_DIR = path.join(__dirname, '../../../__fixtures__/regress'); | ||
| const KNOWN_FAILURES_FILE = path.join(FIXTURE_DIR, 'known-failures.plpgsql-deparser.json'); | ||
| const UPDATE = process.env.UPDATE_KNOWN_FAILURES === '1'; | ||
|
|
||
| const { statements } = JSON.parse( | ||
| zlib.gunzipSync(fs.readFileSync(path.join(FIXTURE_DIR, 'statements.json.gz'))).toString('utf8') | ||
| ) as { statements: Record<string, string> }; | ||
| const knownFailures: string[] = JSON.parse(fs.readFileSync(KNOWN_FAILURES_FILE, 'utf8')); | ||
|
|
||
| const candidates = Object.keys(statements).filter((key) => | ||
| /language\s+'?plpgsql|^\s*do\s/i.test(statements[key]) | ||
| ); | ||
|
|
||
| describe('regress corpus PL/pgSQL round-trip', () => { | ||
| const utils = new PLpgSQLTestUtils(); | ||
|
|
||
| beforeAll(async () => { | ||
| await loadModule(); | ||
| for (const level of ['error', 'warn', 'log'] as const) jest.spyOn(console, level).mockImplementation(() => {}); | ||
| }); | ||
| afterAll(() => jest.restoreAllMocks()); | ||
|
|
||
| it('matches known failures', async () => { | ||
| const failing: string[] = []; | ||
| let tested = 0; | ||
| for (const key of candidates) { | ||
| try { | ||
| const parsed = parsePlPgSQLSync(statements[key]) as any; | ||
| if (!parsed.plpgsql_funcs?.length) continue; | ||
| } catch { | ||
| continue; | ||
| } | ||
| tested++; | ||
| try { | ||
| await utils.expectAstMatch(key, statements[key]); | ||
| } catch { | ||
| failing.push(key); | ||
| } | ||
| } | ||
| expect(tested).toBeGreaterThan(500); | ||
| if (UPDATE) { | ||
| fs.writeFileSync(KNOWN_FAILURES_FILE, JSON.stringify(failing, null, 2) + '\n'); | ||
| } else { | ||
| expect(failing).toEqual(knownFailures); | ||
| } | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.