diff --git a/src/selfhost/pg-dialect.ts b/src/selfhost/pg-dialect.ts index da9b85b482..0be66fdf90 100644 --- a/src/selfhost/pg-dialect.ts +++ b/src/selfhost/pg-dialect.ts @@ -88,6 +88,13 @@ export function translateFunctions(sql: string): string { .replace(/json_extract\(\s*([^,]+?)\s*,\s*'\$\.((?:[A-Za-z0-9_]+\.)+[A-Za-z0-9_]+)'\s*\)/gi, (_m, col, path) => `((${col})::jsonb #>> '{${path.split(".").join(",")}}')`) // json_extract(col, '$.key') → (col::jsonb ->> 'key') (single-level paths) .replace(/json_extract\(\s*([^,]+?)\s*,\s*'\$\.([A-Za-z0-9_]+)'\s*\)/gi, `(($1)::jsonb ->> '$2')`) + // json_each(col) alias → SQLite's json_each() on a JSON ARRAY yields one row per element with a + // `value` column (migrations/0191's linked-issue-claims backfill reads je.value); untranslated on + // Postgres, json_each() there decomposes JSON OBJECTS only and rejects a bare TEXT column outright + // ("function json_each(text) does not exist"), crash-looping the self-host Postgres migration runner + // on startup. json_array_elements_text is Postgres's array-expansion equivalent — cast the column to + // `json` and alias the single output column back to `value` so `je.value` keeps working unchanged. + .replace(/\bjson_each\(\s*([^()]+?)\s*\)\s*(?:AS\s+)?([A-Za-z_][A-Za-z0-9_]*)\b/gi, (_m, col, alias) => `json_array_elements_text((${col})::json) AS ${alias}(value)`) // instr(haystack, needle) → strpos(haystack, needle): both are 1-based first-occurrence index, 0 if // absent -- a direct semantic match, no formula adjustment needed. Postgres has no `instr` builtin at // all (unlike substr, which is SQL-standard and needs no translation) -- every instr() call reaching diff --git a/test/unit/selfhost-pg-dialect.test.ts b/test/unit/selfhost-pg-dialect.test.ts index cc1a29275d..698c289300 100644 --- a/test/unit/selfhost-pg-dialect.test.ts +++ b/test/unit/selfhost-pg-dialect.test.ts @@ -46,6 +46,30 @@ describe("pg-dialect (#977 SQLite → Postgres)", () => { expect(translateFunctions("candidate(x)")).toBe("candidate(x)"); }); + it("REGRESSION: translates json_each(col) array iteration to json_array_elements_text (crash-looped self-host Postgres boot, beta.9)", () => { + // migrations/0191_linked_issue_claims.sql's backfill reads je.value after `FROM pull_requests pr, + // json_each(pr.linked_issues_json) je` — untranslated, Postgres's own json_each() only accepts JSON + // OBJECTS (and rejects a TEXT column outright: "function json_each(text) does not exist"), which crashed + // the self-host Postgres migration runner in a boot loop in production. json_array_elements_text is the + // array-expansion equivalent; the column alias list `(value)` keeps `je.value` readable unchanged. + expect(translateFunctions("json_each(pr.linked_issues_json) je")).toBe("json_array_elements_text((pr.linked_issues_json)::json) AS je(value)"); + // The bare (no `AS`) alias form is what the migration actually uses; the `AS` form must also translate. + expect(translateFunctions("json_each(col) AS alias")).toBe("json_array_elements_text((col)::json) AS alias(value)"); + expect( + translateDdl( + "INSERT INTO linked_issue_claims (repo_full_name, pull_number, issue_number, claimed_at)\n" + + "SELECT pr.repo_full_name, pr.number, CAST(je.value AS INTEGER), pr.updated_at\n" + + "FROM pull_requests pr, json_each(pr.linked_issues_json) je\n" + + "WHERE pr.linked_issues_json != '[]';", + ), + ).toBe( + "INSERT INTO linked_issue_claims (repo_full_name, pull_number, issue_number, claimed_at)\n" + + "SELECT pr.repo_full_name, pr.number, CAST(je.value AS INTEGER), pr.updated_at\n" + + "FROM pull_requests pr, json_array_elements_text((pr.linked_issues_json)::json) AS je(value)\n" + + "WHERE pr.linked_issues_json != '[]';", + ); + }); + it("REGRESSION: translates instr(haystack, needle) to Postgres's strpos (SQLite has no `instr` on Postgres)", () => { expect(translateFunctions("instr(x, '#')")).toBe("strpos(x, '#')"); expect(translateFunctions("instr(ra.target_id, '#') > 0")).toBe("strpos(ra.target_id, '#') > 0");