You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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. */internalinterfaceWireCodec {
/** The Kotlin type JDBC delivers, non-null form. */val kotlinType:TypeName/** Read at [index]. When [nullable], the expression evaluates to `null` for SQL NULL. */funread(index:Int, nullable:Boolean): CodeBlock/** Write the non-null [value] at [index]. */funwrite(index:Int, value:CodeBlock): CodeBlock/** Write SQL NULL at [index]. */funwriteNull(index:Int): CodeBlock
}
Implementations, derived from today's flag combinations:
statementAction = if notNullcodec.write(i, encode(v)) else "%L?.let { %L } ?: %L" of (v, codec.write(i, encode(it)), codec.writeNull(i)).
resultSetAction = if notNulldecode(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.
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
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.
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.
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
JdbcTypeInfo no longer exists; no boolean flag on any type descriptor decides read/write shape.
SqlMappable.kt has fewer distinct SqlMappable implementations than today (currently 8) and AdaptedTypeSqlMappable.statementAction has at most two branches (notNull or not).
Tests in "Test design" exist and pass; ./gradlew :generator:check :gradle-plugin:test passes;
golden regeneration leaves test-scenarios* byte-identical.
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: #280 (single type table to attach codecs to).
Why
norm.generator.JdbcTypeInfo(SqlMappable.kt:358-367) describes how to read and write a wire valuewith four independent flags:
isPrimitive,useSqlTypeHint,getterClassHint,convertOffsetDateTimeToInstant.AdaptedTypeSqlMappable(SqlMappable.kt:387-520) then branches onnotNull × useSqlTypeHintfor writes (four lambdas) andnotNull × isPrimitivefor reads, withrawReadExpression/readExpression/encodedValueExpressionlayering the remaining two flags on top.Meanwhile the plain (adapterless) mappables (
JdbcTypes,NullablePrimitiveDecorator,PostgresSupportedTypes,InstantSqlMappable,JsonSqlMappable) encode the same wire knowledge asecond 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-lineKDoc).
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:
Implementations, derived from today's flag combinations:
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 getteruseSqlTypeHint = 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.jdbcTypeInfobecomesPostgresBaseType.codec: WireCodec.AdaptedTypeSqlMappable(applicationType, adapterPropertyName, notNull, codec):statementAction= ifnotNullcodec.write(i, encode(v))else"%L?.let { %L } ?: %L"of(v, codec.write(i, encode(it)), codec.writeNull(i)).resultSetAction= ifnotNulldecode(codec.read(i, false))else"%L?.let { decode(it) }"ofcodec.read(i, true).ScalarSqlMappable(codec, notNull), whosetypeName = codec.kotlinType.copy(nullable = !notNull),statementAction=codec.write/ thesame
?.let ... ?: writeNullshape,resultSetAction=codec.read(i, !notNull).JdbcTypes,NullablePrimitiveDecorator,PostgresSupportedTypes,InstantSqlMappable,JsonSqlMappablearedeleted;
ArrayTypeDecoratorkeeps taking a delegateSqlMappablefor the element read.ENUM_JDBC_TYPE_INFObecomesENUM_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 runtimeextension
norm.setInt(Int, Int?)(PreparedStatements.kt), while the adapted path emitsvalue?.let { setInt(i, adapter.encode(it)) } ?: setNull(i, Types.INTEGER). Those are two differentgenerated shapes for the same concept. This issue keeps both shapes byte-identical in the goldens.
PrimitiveCodec.writetherefore needs to know whether it is emitting the plain nullable form (call thenorm.setXextension viaMemberName("norm", "setX", isExtension = true)) or the adapted form; modelthat as a
writeNullable(index, nullableValue): CodeBlockonWireCodecwith a defaultimplementation of the
?.let ... ?: writeNullshape, overridden byPrimitiveCodecto emit theextension call. Unifying the two shapes is a separate, visible decision that would regenerate goldens;
do not fold it in here.
Test design
all_typescovers every base type,domainscovers adapted scalars and arrays,type_mappingscovers user adapters,crud_generationcovers nullable primitive writes). Byte-identical or the refactor is wrong.ColumnTypeMappingTest.ktandTypeRepositoryTest.ktassert onresolveMappableType(...)results by type and on rendered
CodeBlocks. Where a test assertsis JdbcTypes.INToris InstantSqlMappable, rewrite it to assert on the renderedresultSetAction(1).toString()andstatementAction(1, CodeBlock.of("v")).toString(), which is what actually matters and survives theclass collapse.
SqlMappableTest.kt(new): for eachPOSTGRES_BASE_TYPESentry, renderread/write for
notNulltrue and false through both the plain and adapted mappable and compare toa 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
JdbcTypeInfono longer exists; no boolean flag on any type descriptor decides read/write shape.SqlMappable.kthas fewer distinctSqlMappableimplementations than today (currently 8) andAdaptedTypeSqlMappable.statementActionhas at most two branches (notNullor not)../gradlew :generator:check :gradle-plugin:testpasses;golden regeneration leaves
test-scenarios*byte-identical.Files
generator/src/main/kotlin/norm/generator/SqlMappable.ktgenerator/src/main/kotlin/norm/generator/PostgresBaseTypes.kt(from SplitTypeRepository.kt; merge the two parallel type tables #280)generator/src/main/kotlin/norm/generator/TypeRepository.ktgenerator/src/main/kotlin/norm/generator/DomainBuilder.kt(domainKotlinBaseTypereadscodec.kotlinType)generator/src/main/kotlin/norm/generator/Main.kt(wireKotlinTypereadscodec.kotlinType)Conventions 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.