fix: normalize partial index IN-list predicates to avoid perpetual rebuild (#473)#491
Conversation
…build (#473) A partial index with an IN-list predicate on a varchar column produced a perpetual spurious "concurrent rebuild" diff. PostgreSQL stores the same predicate two semantically-identical but textually-different ways: - array-level cast (from `WHERE status IN (...)`): `... = ANY ((ARRAY['a'::varchar, ...])::text[])` - element-level cast (round-tripped through the catalog): `... = ANY (ARRAY[('a'::varchar)::text, ...])` `convertAnyArrayToIn` only matched the element-level form (its marker required `= ANY (ARRAY[`, not the wrapped `= ANY ((ARRAY[`), so the two states never normalized to the same string and plan scheduled an endless rebuild. Make `convertAnyArrayToIn` robust to both forms: skip the optional wrapping paren + whole-array cast, and strip the redundant per-element `::text` cast so both collapse to `col IN ('a'::varchar, ...)`. Sibling of #200/#265. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a non-converging diff for partial indexes whose predicates use IN (...) on varchar columns by making predicate normalization canonicalize both catalog-rendered = ANY ((ARRAY[...])::text[]) and dump-reparsed = ANY (ARRAY[...]) forms to the same IN (...) representation.
Changes:
- Enhanced
convertAnyArrayToInto handle optional wrapping parentheses + whole-array casts and to strip redundant per-element::textcasts onvarcharliterals. - Added a regression test case (
issue_473_partial_index_in_list) whose old/new SQL differs only in these equivalent predicate renderings, asserting the resulting plan/diff is empty.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
ir/normalize.go |
Makes = ANY (ARRAY...) → IN (...) normalization robust to both array-level and element-level cast shapes for stable comparisons. |
testdata/diff/create_index/issue_473_partial_index_in_list/old.sql |
Repro “old” schema using a natural IN (...) partial-index predicate. |
testdata/diff/create_index/issue_473_partial_index_in_list/new.sql |
Repro “new” schema using the dump-rendered = ANY ((ARRAY...)::text[]) form. |
testdata/diff/create_index/issue_473_partial_index_in_list/plan.txt |
Asserts the plan output is “No changes detected.” |
testdata/diff/create_index/issue_473_partial_index_in_list/plan.json |
Asserts the JSON plan has no groups (null) for this scenario. |
testdata/diff/create_index/issue_473_partial_index_in_list/plan.sql |
Empty plan SQL for the no-op diff case. |
testdata/diff/create_index/issue_473_partial_index_in_list/diff.sql |
Empty diff SQL for the no-op diff case. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Greptile SummaryThis PR fixes a perpetual spurious diff on partial indexes whose
Confidence Score: 4/5Safe to merge; the fix is tightly scoped to the two known predicate representations and has no effect on existing normalisation paths. The algorithm correctly handles both the array-level-wrapped and element-level-cast forms, and the existing check-constraint tests continue to exercise the shared code path. The two observations — no direct unit tests for the new helpers, and the inner-paren forward scan being theoretically stoppable by a ')' in a future cast syntax — are minor concerns rather than present defects. ir/normalize.go — specifically the arrayWrapped close-paren scan and the new stripRedundantTextCast helper, which lack dedicated unit tests. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["convertAnyArrayToIn(expr)"] --> B{"Find ' = ANY ('"}
B -- "not found" --> Z["return expr unchanged"]
B -- "found" --> C{"expr[pos] == '('?"}
C -- "yes" --> D["arrayWrapped = true, pos++"]
C -- "no" --> E{"HasPrefix ARRAY[?"}
D --> E
E -- "no" --> Z
E -- "yes" --> F["findArrayClose to arrayEnd"]
F --> G{"arrayEnd == -1?"}
G -- "yes" --> Z
G -- "no" --> H["Scan forward for first ')'"]
H --> I{"arrayWrapped?"}
I -- "no" --> K["closeParenIdx = outer ')'"]
I -- "yes" --> J["Skip inner ')' and cast, scan for outer ')'"]
J --> K
K --> L["Split arrayContents by ', '"]
L --> M["stripRedundantTextCast each element"]
M --> N["Return prefix IN values suffix"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["convertAnyArrayToIn(expr)"] --> B{"Find ' = ANY ('"}
B -- "not found" --> Z["return expr unchanged"]
B -- "found" --> C{"expr[pos] == '('?"}
C -- "yes" --> D["arrayWrapped = true, pos++"]
C -- "no" --> E{"HasPrefix ARRAY[?"}
D --> E
E -- "no" --> Z
E -- "yes" --> F["findArrayClose to arrayEnd"]
F --> G{"arrayEnd == -1?"}
G -- "yes" --> Z
G -- "no" --> H["Scan forward for first ')'"]
H --> I{"arrayWrapped?"}
I -- "no" --> K["closeParenIdx = outer ')'"]
I -- "yes" --> J["Skip inner ')' and cast, scan for outer ')'"]
J --> K
K --> L["Split arrayContents by ', '"]
L --> M["stripRedundantTextCast each element"]
M --> N["Return prefix IN values suffix"]
Reviews (1): Last reviewed commit: "fix: normalize partial index IN-list pre..." | Re-trigger Greptile |
…#473) Address PR review feedback: - Add table-driven unit tests for convertAnyArrayToIn and stripRedundantTextCast covering both the array-level and element-level cast forms, plus an explicit assertion that the two equivalent forms converge to the same string. - Update convertAnyArrayToIn doc to note that redundant per-element "::text" casts on varchar literals are stripped (semantically-significant casts are still preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The case asserts conversion to IN(...), not an unchanged expression; rename to reflect that only the values are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
A partial index whose
WHEREpredicate uses anIN-list on avarcharcolumn produced a perpetual spurious diff —planscheduled a concurrent rebuild on every run even when nothing changed.Root cause. PostgreSQL stores the same predicate two semantically-identical but textually-different ways depending on how it reaches the catalog:
pg_get_expr(indpred)WHERE status IN ('new','acknowledged')(user's original DDL)... = ANY ((ARRAY['new'::varchar, ...])::text[])— array-level cast... = ANY (ARRAY[('new'::varchar)::text, ...])— element-level castconvertAnyArrayToIninir/normalize.goonly matched the element-level form — its marker required= ANY (ARRAY[, not the wrapped= ANY ((ARRAY[. So current state (array-level) was left untouched while desired state (element-level) was rewritten toIN (...), the twoWherestrings never matched, andindexesStructurallyEqualreported an endless rebuild. Sibling of #200/#265.Fix. Make
convertAnyArrayToInrobust to both forms:::text[]cast, and::textcast (('x'::varchar)::textand'x'::varchar::text)so both forms collapse to the same canonical
col IN ('x'::varchar, ...). The existing CHECK-constraint normalization (which shares this function) already collapsed casts via its own preprocessing and is unaffected.Fixes #473
Test plan
Added
testdata/diff/create_index/issue_473_partial_index_in_list/whereold.sqluses the naturalIN-list syntax (array-level cast) andnew.sqluses the dump-rendered form (element-level cast). Before the fix this produced a spuriousDROP INDEX+CREATE INDEX; after the fix the diff is empty.Verified end-to-end against live PostgreSQL 17:
dumpthenplanagainst the same DB now reports No changes detected on the first plan. Re-ran the impacted diff categories (create_index,create_table,online,create_policy,create_view,migrate) and the fullirunit suite — all green.🤖 Generated with Claude Code