Skip to content

Replace JdbcTypeInfo flags with a WireCodec #281

Description

Depends on: #280 (single type table to attach codecs to).

Why

norm.generator.JdbcTypeInfo (SqlMappable.kt:358-367) describes how to read and write a wire value
with four independent flags: isPrimitive, useSqlTypeHint, getterClassHint,
convertOffsetDateTimeToInstant. AdaptedTypeSqlMappable (SqlMappable.kt:387-520) then branches on
notNull × useSqlTypeHint for writes (four lambdas) and notNull × isPrimitive for reads, with
rawReadExpression/readExpression/encodedValueExpression layering the remaining two flags on top.
Meanwhile the plain (adapterless) mappables (JdbcTypes, NullablePrimitiveDecorator,
PostgresSupportedTypes, InstantSqlMappable, JsonSqlMappable) encode the same wire knowledge a
second time in a different shape. Adding a wire type today means a new flag combination plus a new
enum entry plus a KDoc paragraph explaining how the flags interact (see getterClassHint's 14-line
KDoc).

Target state

One small interface that says how a wire value is read and written, with one implementation per
kind of JDBC access rather than per flag:

/** How one Postgres wire type is read from a ResultSet and written to a PreparedStatement in generated code. */
internal interface WireCodec {
  /** The Kotlin type JDBC delivers, non-null form. */
  val kotlinType: TypeName

  /** Read at [index]. When [nullable], the expression evaluates to `null` for SQL NULL. */
  fun read(index: Int, nullable: Boolean): CodeBlock

  /** Write the non-null [value] at [index]. */
  fun write(index: Int, value: CodeBlock): CodeBlock

  /** Write SQL NULL at [index]. */
  fun writeNull(index: Int): CodeBlock
}

Implementations, derived from today's flag combinations:

Today Codec
getX/setX, isPrimitive = true (int2/4/8, float4/8, bool) PrimitiveCodec(getter, setter, sqlType, kotlinType): read(nullable=true) = getX(i).takeUnless { wasNull() }
getX/setX, isPrimitive = false (text, numeric, bytea, oid) ObjectGetterCodec(getter, setter, sqlType, kotlinType): read = getX(i), nullability from the getter
useSqlTypeHint = true (json, jsonb, enums) TypesOtherCodec: read = getString(i), write = setObject(i, v, Types.OTHER)
getterClassHint != null, no conversion (date, time, timetz, timestamp, uuid) ClassHintedObjectCodec(kotlinClass, sqlType): read = getObject(i, X::class.java), write = setObject(i, v)
convertOffsetDateTimeToInstant (timestamptz) InstantViaOffsetDateTimeCodec: read = getObject(i, OffsetDateTime::class.java)[?].toInstant(), write = setObject(i, OffsetDateTime.ofInstant(v, ZoneOffset.UTC))

Then:

  • PostgresBaseType.jdbcTypeInfo becomes PostgresBaseType.codec: WireCodec.
  • AdaptedTypeSqlMappable(applicationType, adapterPropertyName, notNull, codec):
    • statementAction = if notNull codec.write(i, encode(v)) else
      "%L?.let { %L } ?: %L" of (v, codec.write(i, encode(it)), codec.writeNull(i)).
    • resultSetAction = if notNull decode(codec.read(i, false)) else
      "%L?.let { decode(it) }" of codec.read(i, true).
  • The plain scalar mappables collapse to one class, ScalarSqlMappable(codec, notNull), whose
    typeName = codec.kotlinType.copy(nullable = !notNull), statementAction = codec.write / the
    same ?.let ... ?: writeNull shape, resultSetAction = codec.read(i, !notNull). JdbcTypes,
    NullablePrimitiveDecorator, PostgresSupportedTypes, InstantSqlMappable, JsonSqlMappable are
    deleted; ArrayTypeDecorator keeps taking a delegate SqlMappable for the element read.
  • ENUM_JDBC_TYPE_INFO becomes ENUM_CODEC = TypesOtherCodec(STRING).

The constraint that makes this safe: generated code must not change

Today the nullable write for a plain primitive is setInt(i, value) resolving to the runtime
extension norm.setInt(Int, Int?) (PreparedStatements.kt), while the adapted path emits
value?.let { setInt(i, adapter.encode(it)) } ?: setNull(i, Types.INTEGER). Those are two different
generated shapes for the same concept. This issue keeps both shapes byte-identical in the goldens.
PrimitiveCodec.write therefore needs to know whether it is emitting the plain nullable form (call the
norm.setX extension via MemberName("norm", "setX", isExtension = true)) or the adapted form; model
that as a writeNullable(index, nullableValue): CodeBlock on WireCodec with a default
implementation of the ?.let ... ?: writeNull shape, overridden by PrimitiveCodec to emit the
extension call. Unifying the two shapes is a separate, visible decision that would regenerate goldens;
do not fold it in here.

Test design

  1. Golden invariance is the primary pin: every scenario (all_types covers every base type,
    domains covers adapted scalars and arrays, type_mappings covers user adapters,
    crud_generation covers nullable primitive writes). Byte-identical or the refactor is wrong.
  2. ColumnTypeMappingTest.kt and TypeRepositoryTest.kt assert on resolveMappableType(...)
    results by type and on rendered CodeBlocks. Where a test asserts is JdbcTypes.INT or
    is InstantSqlMappable, rewrite it to assert on the rendered resultSetAction(1).toString() and
    statementAction(1, CodeBlock.of("v")).toString(), which is what actually matters and survives the
    class collapse.
  3. Add a table-driven test in SqlMappableTest.kt (new): for each POSTGRES_BASE_TYPES entry, render
    read/write for notNull true and false through both the plain and adapted mappable and compare to
    a checked-in expectation string. This is the unit-level pin that codec kind × nullability produces
    the intended text, independent of the live-database scenarios.

Acceptance criteria

  1. JdbcTypeInfo no longer exists; no boolean flag on any type descriptor decides read/write shape.
  2. SqlMappable.kt has fewer distinct SqlMappable implementations than today (currently 8) and
    AdaptedTypeSqlMappable.statementAction has at most two branches (notNull or not).
  3. Tests in "Test design" exist and pass; ./gradlew :generator:check :gradle-plugin:test passes;
    golden regeneration leaves test-scenarios* byte-identical.

Files

  • generator/src/main/kotlin/norm/generator/SqlMappable.kt
  • generator/src/main/kotlin/norm/generator/PostgresBaseTypes.kt (from Split TypeRepository.kt; merge the two parallel type tables #280)
  • generator/src/main/kotlin/norm/generator/TypeRepository.kt
  • generator/src/main/kotlin/norm/generator/DomainBuilder.kt (domainKotlinBaseType reads codec.kotlinType)
  • generator/src/main/kotlin/norm/generator/Main.kt (wireKotlinType reads codec.kotlinType)
  • tests listed above

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