Why
norm.generator.PgCatalogLoader constructs ColumnNullabilityAnalyzer(this)
(PgCatalogLoader.kt:22). The analyzer then reaches back into the loader through eleven
pass-through getters (ColumnNullabilityAnalyzer.kt:86-96: connection, nodeTreeParser,
columnNotNullByRelidAndAttnum, columnNameByRelidAndAttnum, aggregateHasNonNullInitialValue,
alwaysNonNullFunctionOids, neverNullForNonNullInputOids, lagLeadWithDefaultOids,
immutableFunctionOids, nonNullIffFirstArgumentNonNullFunctionOids, isStrictFunction). To make
that possible the loader exposes internal val connection and internal val nodeTreeParser
(PgCatalogLoader.kt:15-17) that exist for no other consumer. The loader's own KDoc on line 21 has to
explain why the analyzer is "a separate collaborator", which is the tell that the split is nominal.
Target state
Introduce a value type that owns the catalog facts the analyzer needs and nothing else:
/**
* Lazily-loaded pg_catalog facts NodeTreeNullabilityAnalyzer consults. Loaded once per connection.
*/
internal class NullabilityCatalog(private val connection: Connection) {
val functionStrictnessByOid: Map<Int, Boolean> by lazy(::loadFunctionStrictness)
val immutableFunctionOids: Set<Int> by lazy(::loadImmutableFunctionOids)
val aggregateHasNonNullInitialValue: Map<Int, Boolean> by lazy(::loadAggregateInitialValues)
val alwaysNonNullFunctionOids: Set<Int> by lazy(::loadAlwaysNonNullFunctions)
val nonNullIffFirstArgumentNonNullFunctionOids: Set<Int> by lazy(::loadNonNullIffFirstArgumentNonNullFunctionOids)
val neverNullForNonNullInputOids: Set<Int> by lazy(::loadNeverNullForNonNullInputOids)
val lagLeadWithDefaultOids: Set<Int> by lazy(::loadLagLeadWithDefaultOids)
val columnNotNullByRelidAndAttnum: Map<Pair<Int, Int>, Boolean> by lazy(::loadColumnNotNull)
val columnNameByRelidAndAttnum: Map<Pair<Int, Int>, String> by lazy(::loadColumnNameByRelidAndAttnum)
val isStrictFunction: (Int) -> Boolean = { oid -> functionStrictnessByOid[oid] == true }
// the private load* functions move here verbatim, KDoc included
}
ColumnNullabilityAnalyzer(connection: Connection, catalog: NullabilityCatalog) owns its own
private val nodeTreeParser = PgNodeTreeParser() (the parser is stateless; sharing one instance buys
nothing). Every loader.x read becomes catalog.x; the eleven getters are deleted.
PgCatalogLoader keeps: functionOverloads (used by SqlParameterInferrer, not by nullability),
the schema-introspection queries (introspectEnums, introspectDomains, loadTableComments,
loadColumnComments, loadPartitionChildren, loadViewColumnNamesByRelidAndAttnum,
lookupProcedureParameters), checkPostgresVersion, and the two nullability entry points
(loadViewColumnNullability, queryColumnNullability, realColumnCount). It composes:
private val nullabilityCatalog = NullabilityCatalog(connection)
private val nullabilityAnalyzer = ColumnNullabilityAnalyzer(connection, nullabilityCatalog)
connection and nodeTreeParser on the loader become private. The class KDoc on PgCatalogLoader
(lines 7-14) and the "separate collaborator" note (line 21) are rewritten to describe the three-way
split in two sentences.
SafeFunctionSignature, SafeCastSignature, SafeOperatorSignature, and FunctionOverload stay
where they are (bottom of PgCatalogLoader.kt) or move with their loaders; NeverNullSafeLists.kt
is untouched.
Test design
Tests that reach the loader's maps directly must be updated, not weakened:
QueryAnalysisTest.kt nested class CatalogLoading (starts ~line 8653) and SafeListSweepTest.kt
read functionStrictnessByOid, neverNullForNonNullInputOids, etc. Point them at
NullabilityCatalog(connection). Grep both files for each moved property name.
ExplainAnalysisTest.kt, NodeTreeProvenanceResolverTest.kt, PgCatalogLoaderVersionCheckTest.kt
should compile unchanged; if one constructs ColumnNullabilityAnalyzer(loader) directly, switch it
to the new constructor.
- Add one test to
QueryAnalysisTest.CatalogLoading: constructing two NullabilityCatalog instances
on the same connection yields equal functionStrictnessByOid maps (pins that the lazies are pure
catalog reads with no loader state involved).
Acceptance criteria
ColumnNullabilityAnalyzer has no reference to PgCatalogLoader; PgCatalogLoader has no
internal val connection or internal val nodeTreeParser.
git grep -n "private val .* get() = loader\." generator/src/main returns nothing.
./gradlew :generator:check :gradle-plugin:test passes; golden regeneration leaves
test-scenarios* unchanged.
Files
- new
generator/src/main/kotlin/norm/generator/NullabilityCatalog.kt
generator/src/main/kotlin/norm/generator/PgCatalogLoader.kt
generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt
generator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt, SafeListSweepTest.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.
Why
norm.generator.PgCatalogLoaderconstructsColumnNullabilityAnalyzer(this)(
PgCatalogLoader.kt:22). The analyzer then reaches back into the loader through elevenpass-through getters (
ColumnNullabilityAnalyzer.kt:86-96:connection,nodeTreeParser,columnNotNullByRelidAndAttnum,columnNameByRelidAndAttnum,aggregateHasNonNullInitialValue,alwaysNonNullFunctionOids,neverNullForNonNullInputOids,lagLeadWithDefaultOids,immutableFunctionOids,nonNullIffFirstArgumentNonNullFunctionOids,isStrictFunction). To makethat possible the loader exposes
internal val connectionandinternal val nodeTreeParser(
PgCatalogLoader.kt:15-17) that exist for no other consumer. The loader's own KDoc on line 21 has toexplain why the analyzer is "a separate collaborator", which is the tell that the split is nominal.
Target state
Introduce a value type that owns the catalog facts the analyzer needs and nothing else:
ColumnNullabilityAnalyzer(connection: Connection, catalog: NullabilityCatalog)owns its ownprivate val nodeTreeParser = PgNodeTreeParser()(the parser is stateless; sharing one instance buysnothing). Every
loader.xread becomescatalog.x; the eleven getters are deleted.PgCatalogLoaderkeeps:functionOverloads(used bySqlParameterInferrer, not by nullability),the schema-introspection queries (
introspectEnums,introspectDomains,loadTableComments,loadColumnComments,loadPartitionChildren,loadViewColumnNamesByRelidAndAttnum,lookupProcedureParameters),checkPostgresVersion, and the two nullability entry points(
loadViewColumnNullability,queryColumnNullability,realColumnCount). It composes:connectionandnodeTreeParseron the loader becomeprivate. The class KDoc onPgCatalogLoader(lines 7-14) and the "separate collaborator" note (line 21) are rewritten to describe the three-way
split in two sentences.
SafeFunctionSignature,SafeCastSignature,SafeOperatorSignature, andFunctionOverloadstaywhere they are (bottom of
PgCatalogLoader.kt) or move with their loaders;NeverNullSafeLists.ktis untouched.
Test design
Tests that reach the loader's maps directly must be updated, not weakened:
QueryAnalysisTest.ktnested classCatalogLoading(starts ~line 8653) andSafeListSweepTest.ktread
functionStrictnessByOid,neverNullForNonNullInputOids, etc. Point them atNullabilityCatalog(connection). Grep both files for each moved property name.ExplainAnalysisTest.kt,NodeTreeProvenanceResolverTest.kt,PgCatalogLoaderVersionCheckTest.ktshould compile unchanged; if one constructs
ColumnNullabilityAnalyzer(loader)directly, switch itto the new constructor.
QueryAnalysisTest.CatalogLoading: constructing twoNullabilityCataloginstanceson the same connection yields equal
functionStrictnessByOidmaps (pins that the lazies are purecatalog reads with no loader state involved).
Acceptance criteria
ColumnNullabilityAnalyzerhas no reference toPgCatalogLoader;PgCatalogLoaderhas nointernal val connectionorinternal val nodeTreeParser.git grep -n "private val .* get() = loader\." generator/src/mainreturns nothing../gradlew :generator:check :gradle-plugin:testpasses; golden regeneration leavestest-scenarios*unchanged.Files
generator/src/main/kotlin/norm/generator/NullabilityCatalog.ktgenerator/src/main/kotlin/norm/generator/PgCatalogLoader.ktgenerator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.ktgenerator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt,SafeListSweepTest.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.