Skip to content

Break the PgCatalogLoaderColumnNullabilityAnalyzer back-reference #282

Description

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

  1. ColumnNullabilityAnalyzer has no reference to PgCatalogLoader; PgCatalogLoader has no
    internal val connection or internal val nodeTreeParser.
  2. git grep -n "private val .* get() = loader\." generator/src/main returns nothing.
  3. ./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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions