|
| 1 | +#!/usr/bin/env ts-node |
| 2 | +import * as path from 'path'; |
| 3 | +import * as fs from 'fs'; |
| 4 | +import { sync as globSync } from 'glob'; |
| 5 | +import { parse, deparse } from 'libpg-query'; |
| 6 | +import { ParseResult, RawStmt } from '@pgsql/types'; |
| 7 | +import { deparse as ourDeparse } from '../src'; |
| 8 | +import { cleanTree } from '../src/utils'; |
| 9 | + |
| 10 | +const FIXTURE_DIR = path.join(__dirname, '../../../__fixtures__/kitchen-sink'); |
| 11 | +const OUT_DIR = path.join(__dirname, '../../../__fixtures__/generated'); |
| 12 | + |
| 13 | +function ensureDir(dir: string) { |
| 14 | + if (!fs.existsSync(dir)) { |
| 15 | + fs.mkdirSync(dir, { recursive: true }); |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +ensureDir(OUT_DIR); |
| 20 | + |
| 21 | +const fixtures = globSync(path.join(FIXTURE_DIR, '**/*.sql')); |
| 22 | + |
| 23 | +function extractOriginalSQL(originalSQL: string, rawStmt: RawStmt, isFirst: boolean = false): string | null { |
| 24 | + let extracted: string | null = null; |
| 25 | + |
| 26 | + if (rawStmt.stmt_location !== undefined && rawStmt.stmt_len !== undefined) { |
| 27 | + // Check if we need to adjust location - if the character before the location looks like part of a SQL keyword |
| 28 | + let adjustedLocation = rawStmt.stmt_location; |
| 29 | + if (rawStmt.stmt_location > 0) { |
| 30 | + const charBefore = originalSQL[rawStmt.stmt_location - 1]; |
| 31 | + const charAtLocation = originalSQL[rawStmt.stmt_location]; |
| 32 | + // If the char before looks like it should be part of the statement (e.g., 'C' before 'REATE') |
| 33 | + if (/[A-Za-z]/.test(charBefore) && /[A-Za-z]/.test(charAtLocation)) { |
| 34 | + adjustedLocation = rawStmt.stmt_location - 1; |
| 35 | + } |
| 36 | + } |
| 37 | + extracted = originalSQL.substring(adjustedLocation, adjustedLocation + rawStmt.stmt_len); |
| 38 | + } else if (rawStmt.stmt_location !== undefined && rawStmt.stmt_len === undefined) { |
| 39 | + // We have location but no length - extract from location to end of file |
| 40 | + let adjustedLocation = rawStmt.stmt_location; |
| 41 | + if (rawStmt.stmt_location > 0) { |
| 42 | + const charBefore = originalSQL[rawStmt.stmt_location - 1]; |
| 43 | + const charAtLocation = originalSQL[rawStmt.stmt_location]; |
| 44 | + // If the char before looks like it should be part of the statement (e.g., 'C' before 'REATE') |
| 45 | + if (/[A-Za-z]/.test(charBefore) && /[A-Za-z]/.test(charAtLocation)) { |
| 46 | + adjustedLocation = rawStmt.stmt_location - 1; |
| 47 | + } |
| 48 | + } |
| 49 | + extracted = originalSQL.substring(adjustedLocation); |
| 50 | + } else if (isFirst && rawStmt.stmt_len !== undefined) { |
| 51 | + // For first statement when location is missing but we have length |
| 52 | + extracted = originalSQL.substring(0, rawStmt.stmt_len); |
| 53 | + } else if (isFirst && rawStmt.stmt_location === undefined && rawStmt.stmt_len === undefined) { |
| 54 | + // For first statement when both location and length are missing, use entire SQL |
| 55 | + extracted = originalSQL; |
| 56 | + } |
| 57 | + |
| 58 | + if (extracted) { |
| 59 | + // Split into lines to handle leading whitespace and comments properly |
| 60 | + const lines = extracted.split('\n'); |
| 61 | + let startLineIndex = 0; |
| 62 | + |
| 63 | + // Find the first line that contains actual SQL content |
| 64 | + for (let i = 0; i < lines.length; i++) { |
| 65 | + const line = lines[i].trim(); |
| 66 | + // Skip empty lines and comment-only lines |
| 67 | + if (line === '' || line.startsWith('--')) { |
| 68 | + continue; |
| 69 | + } |
| 70 | + startLineIndex = i; |
| 71 | + break; |
| 72 | + } |
| 73 | + |
| 74 | + // Reconstruct from the first SQL line, preserving the original indentation of that line |
| 75 | + if (startLineIndex < lines.length) { |
| 76 | + const resultLines = lines.slice(startLineIndex); |
| 77 | + extracted = resultLines.join('\n').trim(); |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + return extracted; |
| 82 | +} |
| 83 | + |
| 84 | +async function main() { |
| 85 | + // Collect only files with differences between deparsers |
| 86 | + const results: Record<string, { upstream?: string; deparsed?: string; original: string }> = {}; |
| 87 | + |
| 88 | + for (const fixturePath of fixtures) { |
| 89 | + const relPath = path.relative(FIXTURE_DIR, fixturePath); |
| 90 | + const sql = fs.readFileSync(fixturePath, 'utf-8'); |
| 91 | + let parseResult: ParseResult; |
| 92 | + try { |
| 93 | + parseResult = await parse(sql); |
| 94 | + } catch (err: any) { |
| 95 | + console.error(`Failed to parse ${relPath}:`, err); |
| 96 | + continue; |
| 97 | + } |
| 98 | + |
| 99 | + for (let idx = 0; idx < parseResult.stmts.length; idx++) { |
| 100 | + const stmt = parseResult.stmts[idx]; |
| 101 | + |
| 102 | + // Extract original SQL using location info |
| 103 | + const originalSql = extractOriginalSQL(sql, stmt, idx === 0); |
| 104 | + if (!originalSql) { |
| 105 | + console.error(`Failed to extract original SQL for statement ${idx + 1} in ${relPath}`); |
| 106 | + continue; |
| 107 | + } |
| 108 | + |
| 109 | + // Get source of truth: cleanTree(parse(original)) |
| 110 | + let sourceOfTruthAst: any; |
| 111 | + try { |
| 112 | + const originalParsed = await parse(originalSql); |
| 113 | + sourceOfTruthAst = cleanTree(originalParsed.stmts?.[0]?.stmt); |
| 114 | + } catch (err: any) { |
| 115 | + console.error(`Failed to parse original SQL for statement ${idx + 1} in ${relPath}:`, err); |
| 116 | + continue; |
| 117 | + } |
| 118 | + |
| 119 | + // Get upstream deparse and its AST |
| 120 | + let upstreamSql: string | undefined; |
| 121 | + let upstreamAst: any; |
| 122 | + try { |
| 123 | + upstreamSql = await deparse({ version: 170000, stmts: [stmt] }); |
| 124 | + const upstreamParsed = await parse(upstreamSql); |
| 125 | + upstreamAst = cleanTree(upstreamParsed.stmts?.[0]?.stmt); |
| 126 | + } catch (err: any) { |
| 127 | + console.error(`Failed to process upstream deparse for statement ${idx + 1} in ${relPath}:`, err); |
| 128 | + continue; |
| 129 | + } |
| 130 | + |
| 131 | + // Get our deparse and its AST |
| 132 | + let ourDeparsedSql: string | undefined; |
| 133 | + let ourAst: any; |
| 134 | + try { |
| 135 | + ourDeparsedSql = ourDeparse(stmt.stmt); |
| 136 | + const ourParsed = await parse(ourDeparsedSql); |
| 137 | + ourAst = cleanTree(ourParsed.stmts?.[0]?.stmt); |
| 138 | + } catch (err: any) { |
| 139 | + console.error(`Failed to process our deparse for statement ${idx + 1} in ${relPath}:`, err); |
| 140 | + // Continue with just upstream comparison |
| 141 | + } |
| 142 | + |
| 143 | + // Compare ASTs to source of truth only |
| 144 | + const upstreamMatches = JSON.stringify(upstreamAst) === JSON.stringify(sourceOfTruthAst); |
| 145 | + const ourMatches = ourAst ? JSON.stringify(ourAst) === JSON.stringify(sourceOfTruthAst) : false; |
| 146 | + |
| 147 | + // Only include if either deparser differs from original |
| 148 | + if (!upstreamMatches || !ourMatches) { |
| 149 | + const key = `${relPath.replace(/\.sql$/, '')}-${idx + 1}.sql`; |
| 150 | + results[key] = { |
| 151 | + original: originalSql, |
| 152 | + // Show upstream only if it differs from original |
| 153 | + ...(!upstreamMatches && upstreamSql && { upstream: upstreamSql }), |
| 154 | + // Show our deparser only if it differs from original |
| 155 | + ...(!ourMatches && ourDeparsedSql && { deparsed: ourDeparsedSql }) |
| 156 | + }; |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + // Write aggregated JSON to output file |
| 162 | + const outputFile = path.join(OUT_DIR, 'upstream-diff.json'); |
| 163 | + fs.writeFileSync(outputFile, JSON.stringify(results, null, 2)); |
| 164 | + console.log(`Wrote JSON to ${outputFile}`); |
| 165 | +} |
| 166 | + |
| 167 | +main().catch(console.error); |
0 commit comments