Depends on: #274 (so there is a single call site that needs the derived views).
Why
generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt scans :rtable five times with five
near-identical loops:
| Method |
Lines |
Yields |
parseRangeTable |
109-119 |
varno → relid for rtekind 0 |
parseGroupRteMap |
137-154 |
(groupVarno, attr) → (baseVarno, baseAttno) for rtekind 9, by textual :varno/:varattno scan of each :groupexprs block |
parseGroupRteExpressions |
181-193 |
groupVarno → List<PgNodeExpression> for rtekind 9, by parseExpression of each block |
parseSubqueryRangeTable |
386-400 |
varno → {QUERY} text for rtekind 1 |
parseCteRangeTableEntries |
452-463 |
varno → NodeTreeCteReference for rtekind 6 |
parseRangeTableEntries |
481-511 |
varno → RangeTableEntry for every kind |
The last one already subsumes the first, fourth, and fifth exactly (same field extraction, same
1-based indexing). Only the two GROUP-RTE methods read a field (:groupexprs) that
RangeTableEntry.Other(9) does not carry.
Target state
Model
Add to RangeTableEntry in PgNodeExpression.kt:
/**
* `rtekind 9`: a PostgreSQL 18+ `*GROUP*` RTE. [groupExpressionBlocks] are the raw `{...}` blocks of
* `:groupexprs`, in order, so `varattno - 1` indexes into them.
*/
data class Group(val groupExpressionBlocks: List<String>) : RangeTableEntry
parseRangeTableEntries gains a 9 -> branch producing Group(splitBraceBlocks(groupExprsContent)),
falling back to Other(9) when :groupexprs is absent (matching today's return@forEachIndexed,
which skips the entry entirely; skipping vs Other(9) is unobservable because no caller reads
Other).
Derived views
In PgNodeExpression.kt, next to RangeTableEntry, as extension functions on
Map<Int, RangeTableEntry>:
internal fun Map<Int, RangeTableEntry>.baseRelations(): Map<Int, Int>
internal fun Map<Int, RangeTableEntry>.subqueryBlocks(): Map<Int, String>
internal fun Map<Int, RangeTableEntry>.cteReferences(): Map<Int, NodeTreeCteReference>
internal fun Map<Int, RangeTableEntry>.groupExpressions(parser: PgNodeTreeParser): Map<Int, List<PgNodeExpression>>
internal fun Map<Int, RangeTableEntry>.groupRteMap(): Map<Pair<Int, Int>, Pair<Int, Int>>
Each is a mapNotNull/filterIsInstance over the entries. groupRteMap must preserve today's
semantics exactly: for each block, the first textual :varno and :varattno (today's
extractIntField), not parseExpression(block) as? Var. The two differ when a group expression is
not a bare VAR (a FUNCEXPR containing a VAR, or an unmodelled node type whose VAR child is
lost to Unknown). Whether that textual rule is right is a separate question; this issue keeps it.
To do that, extractIntField (currently private on the parser) is either made internal or the
derivation is expressed as parser.firstVarnoAndVarattno(block); pick whichever keeps the parser's
surface smaller.
Deletions
Delete parseRangeTable, parseGroupRteMap, parseGroupRteExpressions, parseSubqueryRangeTable,
parseCteRangeTableEntries. Update the KDoc on RangeTableEntry (currently lines 292-301 of
PgNodeExpression.kt) which lists the deleted methods as contrast.
Call sites
After #274 the derivations are consumed in buildQueryBlockScope (one parseRangeTableEntries
call, then .baseRelations(), .subqueryBlocks(), .cteReferences(), .groupRteMap()),
analyzeCteBodyNullability / analyzeSetOperationBranches (.baseRelations(), .subqueryBlocks()),
mergeAbsentVarnos (already uses parseRangeTableEntries), and
NodeTreeNullabilityAnalyzer.extractColumnNullability:141 (.groupExpressions(parser)).
NodeTreeProvenanceResolver already uses parseRangeTableEntries.
Test design
PgNodeTreeParserTest.kt has tests for each deleted method. Rewrite each to call
parseRangeTableEntries(...) plus the corresponding derivation and assert the same result. Do not
drop any assertion.
- Add one test for
Group: a hand-written :rtable with an rtekind 9 entry whose :groupexprs
holds a {FUNCEXPR ... :args ({VAR :varno 1 :varattno 2 ...})} block. Assert groupRteMap() maps
(groupVarno, 1) → (1, 2) (textual rule) and groupExpressions(parser)[groupVarno][0] is a
FuncExpr. This pins that the two derivations intentionally disagree on non-Var expressions.
QueryAnalysisTest Grouping nested class (about 1200 tests-lines, PG18 GROUP RTE coverage) and
goldens are the end-to-end pin.
Acceptance criteria
PgNodeTreeParser has exactly one method that iterates :rtable.
- The five deleted method names appear nowhere in
generator/.
- Tests in "Test design" exist and pass;
./gradlew :generator:check :gradle-plugin:test passes;
golden regeneration leaves test-scenarios* unchanged.
Files
generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt
generator/src/main/kotlin/norm/generator/PgNodeExpression.kt
generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt
generator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.kt
generator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.kt
Conventions every issue inherits
- Repo root:
/Volumes/Code/3rd-party/norm. Module under change is almost always generator/.
- Style: 2-space indent, 120 columns, full words in identifiers (
parameter, not param), no
section-separator comments, @Nested classes group tests. Format with ./gradlew spotlessApply.
- TDD: tests are written first or alongside. For a behavior-preserving refactor the existing suite is
the pin; each issue says which tests must also be added.
- Golden files under
test-scenarios*/ are never hand-edited. A refactor is behavior-preserving only
if ./gradlew :gradle-plugin:generateGoldenFiles leaves git status --porcelain test-scenarios test-scenarios-frameworks empty.
- Verification for a
generator/ change: ./gradlew :generator:check :gradle-plugin:test (Docker
required). Then the golden regeneration check above.
- Commit message explains the design decision, not the diff.
- Never run
./gradlew clean or disable the configuration/build cache to "fix" a build problem.
Depends on: #274 (so there is a single call site that needs the derived views).
Why
generator/src/main/kotlin/norm/generator/PgNodeTreeParser.ktscans:rtablefive times with fivenear-identical loops:
parseRangeTablevarno → relidforrtekind 0parseGroupRteMap(groupVarno, attr) → (baseVarno, baseAttno)forrtekind 9, by textual:varno/:varattnoscan of each:groupexprsblockparseGroupRteExpressionsgroupVarno → List<PgNodeExpression>forrtekind 9, byparseExpressionof each blockparseSubqueryRangeTablevarno → {QUERY} textforrtekind 1parseCteRangeTableEntriesvarno → NodeTreeCteReferenceforrtekind 6parseRangeTableEntriesvarno → RangeTableEntryfor every kindThe last one already subsumes the first, fourth, and fifth exactly (same field extraction, same
1-based indexing). Only the two GROUP-RTE methods read a field (
:groupexprs) thatRangeTableEntry.Other(9)does not carry.Target state
Model
Add to
RangeTableEntryinPgNodeExpression.kt:parseRangeTableEntriesgains a9 ->branch producingGroup(splitBraceBlocks(groupExprsContent)),falling back to
Other(9)when:groupexprsis absent (matching today'sreturn@forEachIndexed,which skips the entry entirely; skipping vs
Other(9)is unobservable because no caller readsOther).Derived views
In
PgNodeExpression.kt, next toRangeTableEntry, as extension functions onMap<Int, RangeTableEntry>:Each is a
mapNotNull/filterIsInstanceover the entries.groupRteMapmust preserve today'ssemantics exactly: for each block, the first textual
:varnoand:varattno(today'sextractIntField), notparseExpression(block) as? Var. The two differ when a group expression isnot a bare
VAR(aFUNCEXPRcontaining aVAR, or an unmodelled node type whoseVARchild islost to
Unknown). Whether that textual rule is right is a separate question; this issue keeps it.To do that,
extractIntField(currently private on the parser) is either madeinternalor thederivation is expressed as
parser.firstVarnoAndVarattno(block); pick whichever keeps the parser'ssurface smaller.
Deletions
Delete
parseRangeTable,parseGroupRteMap,parseGroupRteExpressions,parseSubqueryRangeTable,parseCteRangeTableEntries. Update the KDoc onRangeTableEntry(currently lines 292-301 ofPgNodeExpression.kt) which lists the deleted methods as contrast.Call sites
After #274 the derivations are consumed in
buildQueryBlockScope(oneparseRangeTableEntriescall, then
.baseRelations(),.subqueryBlocks(),.cteReferences(),.groupRteMap()),analyzeCteBodyNullability/analyzeSetOperationBranches(.baseRelations(),.subqueryBlocks()),mergeAbsentVarnos(already usesparseRangeTableEntries), andNodeTreeNullabilityAnalyzer.extractColumnNullability:141(.groupExpressions(parser)).NodeTreeProvenanceResolveralready usesparseRangeTableEntries.Test design
PgNodeTreeParserTest.kthas tests for each deleted method. Rewrite each to callparseRangeTableEntries(...)plus the corresponding derivation and assert the same result. Do notdrop any assertion.
Group: a hand-written:rtablewith anrtekind 9entry whose:groupexprsholds a
{FUNCEXPR ... :args ({VAR :varno 1 :varattno 2 ...})}block. AssertgroupRteMap()maps(groupVarno, 1) → (1, 2)(textual rule) andgroupExpressions(parser)[groupVarno][0]is aFuncExpr. This pins that the two derivations intentionally disagree on non-Varexpressions.QueryAnalysisTestGroupingnested class (about 1200 tests-lines, PG18 GROUP RTE coverage) andgoldens are the end-to-end pin.
Acceptance criteria
PgNodeTreeParserhas exactly one method that iterates:rtable.generator/../gradlew :generator:check :gradle-plugin:testpasses;golden regeneration leaves
test-scenarios*unchanged.Files
generator/src/main/kotlin/norm/generator/PgNodeTreeParser.ktgenerator/src/main/kotlin/norm/generator/PgNodeExpression.ktgenerator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.ktgenerator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.ktgenerator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.ktConventions every issue inherits
/Volumes/Code/3rd-party/norm. Module under change is almost alwaysgenerator/.parameter, notparam), nosection-separator comments,
@Nestedclasses group tests. Format with./gradlew spotlessApply.the pin; each issue says which tests must also be added.
test-scenarios*/are never hand-edited. A refactor is behavior-preserving onlyif
./gradlew :gradle-plugin:generateGoldenFilesleavesgit status --porcelain test-scenarios test-scenarios-frameworksempty.generator/change:./gradlew :generator:check :gradle-plugin:test(Dockerrequired). Then the golden regeneration check above.
./gradlew cleanor disable the configuration/build cache to "fix" a build problem.