Skip to content

Conversation

@deusaquilus
Copy link
Member

@deusaquilus deusaquilus commented Nov 30, 2025

This PR fixes two fixes two issues

Issue A - Map/Contramap for R2dbc were not working

Previously, using contramap on encoders or map on decoders would break R2DBC's null handling because the type information used for bindNull() would change to the mapped type instead of preserving the original database type.

Why This Mattered for R2DBC

Unlike JDBC, which uses integer type codes (e.g., Types.TIMESTAMP) for setNull(), R2DBC requires the actual Java class for bindNull(). When you transform an encoder with contramap, the type parameter changes, which previously meant the wrong class would be passed to bindNull().

Previous Workaround Example

Before this fix, we had to use verbose object: R2dbcEncoderAny declarations with custom setNull implementations:

kotlin
// OLD: Verbose workaround needed
override val JDateEncoder: SqlEncoder<Connection, Statement, Date> =
  object: R2dbcEncoderAny<Date>(NA, Date::class, { ctx, v, i ->
    val odt = OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ZoneOffset.UTC)
    ctx.stmt.bind(i, odt)
  }) {
    override val originalType: KClass<*> = OffsetDateTime::class
    override val setNull: (Int, Statement, Int) -> Unit =
      { index, stmt, _ -> stmt.bindNull(index, originalType.javaObjectType) }
  }

This was necessary because:

  • SQL Server's R2DBC driver doesn't support binding java.util.Date directly
  • We need to convert it to OffsetDateTime for binding
  • But bindNull() must be called with OffsetDateTime::class.java, not Date::class.java
  • Without the custom setNull, it would incorrectly call stmt.bindNull(index, Date::class.javaObjectType)

The same issue affected decoders - we couldn't use map to transform decoded values because the type information would be lost.

Solution

Introduced an originalType: KClass<*> field to both EncoderAny and DecoderAny:

  • Purpose: Stores the original database-facing type at creation time
  • Behavior: Remains untouched through all contramap/map transformations
  • Usage: Used by R2DBC's bindNull() to pass the correct Java class to the driver

Implementation Details

kotlin
open class EncoderAny<T: Any, TypeId: Any, Session, Stmt> @PublishedApi internal constructor(
  // ... other fields
  /**
   * The original type of this encoder at the time of its initial creation.
   * This field remains unchanged through transformations like [contramap] and [transformFrom].
   * Used in R2DBC's bindNull operation where the driver requires the actual Java class
   * of the original type rather than a JDBC type integer.
   */
  open val originalType: KClass<*>,
  // ... other fields
)
  • Internal constructor: Accepts originalType parameter (used by contramap)
  • Public constructor: Automatically sets originalType = type (convenient for normal usage)
  • Transformation: contramap passes through the originalType unchanged

Benefits

1. Cleaner, More Idiomatic Code

kotlin
// NEW: Clean and idiomatic
override val JDateEncoder: R2dbcEncoderAny<Date> =
  JOffsetDateTimeEncoder.contramap { v: Date ->
    OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ZoneOffset.UTC)
  }

override val JDateDecoder: R2dbcDecoderAny<Date> =
  JOffsetDateTimeDecoder.map { odt: OffsetDateTime ->
    Date.from(odt.toInstant())
  }

The encoder automatically knows to use OffsetDateTime for bindNull() because that's the originalType from JOffsetDateTimeEncoder.

2. Works for Complex Type Conversions

Another example - UUID as String in databases that don't support native UUID:

kotlin
// OLD: Needed custom setNull
override val JUuidEncoder: SqlEncoder<Connection, Statement, UUID> =
  object: R2dbcEncoderAny<UUID>(NA, UUID::class, { ctx, v, i -> 
    ctx.stmt.bind(i, v.toString()) 
  }) {
    override val originalType: KClass<*> = String::class
    override val setNull: (Int, Statement, Int) -> Unit =
      { index, stmt, _ -> stmt.bindNull(index, String::class.java) }
  }

// NEW: Simple contramap
override val JUuidEncoder: R2dbcEncoderAny<UUID> =
  R2dbcBasicEncoding.StringEncoder.contramap { v: UUID -> v.toString() }

3. Consistent with Terpal Philosophy

contramap and map are fundamental functional programming patterns that align with Terpal's compositional approach to building encoders/decoders. This fix removes the friction that previously prevented their use in R2DBC contexts.

Changes Summary

  • Added originalType field to EncoderAny and DecoderAny
  • Refactored all custom R2DBC encoders/decoders to use contramap/map:
    • R2dbcTimeEncodingSqlServer: 7 encoders/decoders
    • R2dbcTimeEncodingOracle: 8 encoders/decoders
    • R2dbcTimeEncodingH2: Already using the pattern
    • R2dbcUuidEncodingString: UUID ↔ String conversion
  • Eliminated all object: R2dbcEncoderAny workarounds with custom setNull implementations

Issue B - SQL Server java.util.Date was decoding with wrong timezone

The JavaTestEntity tests were failing on SQL Server with an AssertionError when comparing java.util.Date values. The date being read back from the database didn't match the date that was inserted.

Root Cause

There was a type mismatch between the SQL Server schema and the JDBC encoder/decoder:

  • Schema: The java.util.Date column was defined as DATETIMEOFFSET (SQL Server's timezone-aware datetime type)
  • Encoder/Decoder: The default JdbcTimeEncoding was using Types.TIMESTAMP with setTimestamp()/getTimestamp() methods
// Previous (incorrect) encoding for SQL Server DATETIMEOFFSET
override val JDateEncoder = JdbcEncoderAny(Types.TIMESTAMP, ...) { ctx, v, i ->
  ctx.stmt.setTimestamp(i, Timestamp(v.getTime()), Calendar.getInstance(...))
}
override val JDateDecoder = JdbcDecoderAny(...) { ctx, i ->
  java.util.Date(ctx.row.getTimestamp(i, Calendar.getInstance(...)).getTime())
}

When getTimestamp() is called on a DATETIMEOFFSET column, the SQL Server JDBC driver performs implicit timezone conversions that can alter the actual timestamp value, causing the round-trip test to fail.

Solution

Introduced SqlServerTimeEncoding that properly handles java.util.Date for DATETIMEOFFSET columns by:

  1. Using Types.TIMESTAMP_WITH_TIMEZONE instead of Types.TIMESTAMP
  2. Converting through OffsetDateTime as an intermediary type
  3. Using setObject()/getObject() with the correct type class
object SqlServerTimeEncoding: JdbcTimeEncoding() {
  override val JDateEncoder = JdbcEncoderAny(Types.TIMESTAMP_WITH_TIMEZONE, ...) { ctx, v, i ->
    val offsetDateTime = OffsetDateTime.ofInstant(v.toInstant(), ctx.timeZone.toJavaZoneId())
    ctx.stmt.setObject(i, offsetDateTime, Types.TIMESTAMP_WITH_TIMEZONE)
  }
  override val JDateDecoder = JdbcDecoderAny(...) { ctx, i ->
    val offsetDateTime = ctx.row.getObject(i, OffsetDateTime::class.java)
    java.util.Date.from(offsetDateTime.toInstant())
  }
}

This ensures proper handling of timezone-aware datetime columns and preserves the exact instant across encode/decode cycles.


@deusaquilus deusaquilus merged commit 4ed02ec into main Nov 30, 2025
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants