fix: guard nullable primitive column reads with wasNull() - #1
Open
wileyatnelo wants to merge 1 commit into
Open
Conversation
JDBC's primitive getters return 0/0.0/false for a SQL NULL, so reading a nullable column with getLong/getInt/getShort/getByte/getDouble/getFloat/ getBoolean cannot distinguish "absent" from "zero". The generator already got the *type* right -- makeType sets `IsNull: !col.NotNull` and ktType.String() appends `?` -- but jdbcGet branched on IsNull only for JSON and UUID columns before falling through to a bare `results.get<Type>(n)`. So a nullable column was emitted as `Long?` and then filled with a value that could never be null. That combination is worse than a plainly wrong type: the declared type tells callers to handle null, so they write `?: fallback` or `x == null` branches that are dead code and never fire. Guard the primitive getters with wasNull(). takeUnless keeps the read a single expression, which is what ResultSet() splices in per constructor argument, and it evaluates the getter before the lambda so wasNull() still refers to this column. Not a breaking change for consumers: no emitted type changes, only the runtime value for rows that were previously returning a wrong answer. A genuine 0 still reads as 0. NOT NULL columns are untouched, as are getters that already return a reference type (getString, getBigDecimal, getObject), which report NULL as null on their own. Adds a `nulltest` example covering every affected type against a real Postgres, including the zero-valued control row -- without it a mapper that returned null for everything would also pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
JDBC's primitive getters —
getInt,getLong,getShort,getByte,getDouble,getFloat,getBoolean— return0/0.0/falsefor a SQL NULL. Reading a nullable column through one, with nowasNull()check, makes "the value is absent" indistinguishable from "the value is zero".The generator already got the type right.
makeTypesetsIsNull: !col.NotNullandktType.String()appends?. ButjdbcGetbranched onIsNullonly for JSON and UUID columns before falling through to a bareresults.get<Type>(n). So the two halves disagreed: a nullable column was emitted asLong?and then filled with a value that could never be null.That combination is worse than a plainly wrong type. Because the declared type says the value can be null, callers write
?: fallbackandx == nullbranches — and those branches are dead code that never fires. A non-null type would at least have been honest.Found while auditing
nelo/api-v2, where a nullablemarketplace_shipping_option.amount(the "ask the carrier for a live rate" sentinel) was reading back as0. Downstream code detects that state withamount == null, so it silently took the "already priced at zero" path. In that repo the value happens to be discarded before it reaches a customer, but the same table is read correctly asnullvia Ebean two lines away inProductRepository.resolveShippingProfiles— one read path returningnulland the other0for the same row.The fix
One branch in
jdbcGet, plus anisPrimitive()predicate alongside the existingIsTime()/IsUUID()/IsBigDecimal()ones:takeUnlesskeeps the read a single expression, which is whatResultSet()splices in per constructor argument, and it evaluates the getter before the lambda — sowasNull()still refers to this column, per the JDBC contract.Untouched:
NOT NULLcolumns, array columns (read viagetArray), and getters that already return a reference type (getString,getBigDecimal,getObject), which report NULL asnullon their own.Is this breaking for consumers?
No. No emitted type changes — the field was already
Long?. Only the runtime value changes,0→null, and only for rows that were previously returning a wrong answer for a case the declared type already forced every caller to handle. Kotlin that compiles today still compiles.Verified against the
api-v2monolith: regenerated all 10 sqlc modules with this build and./gradlew compileKotlinpasses. Seven call sites gained the guard (one nullablebigint, six nullable lat/longfloats); theNOT NULLbooleans and theCAST(... AS bigint)aggregate balances were correctly left alone.The one thing to check before adopting is any consumer that has come to depend on the
0.Tests
Unit (
internal/core/wasnull_test.go) —jdbcGetfor all seven primitives nullable and non-null, plus regression guards thatString/BigDecimal/UUID/OffsetDateTimeand primitive arrays stay unwrapped.Integration (
examples/.../nulltest) — a new example against real Postgres, coveringbigint,integer,smallint,double precision,real,boolean, withNOT NULLcontrols. Three tests: NULLs read asnull, genuine zeros still read as0, and the two stay distinguishable across a multi-row read.The zero-valued row is the half that makes this meaningful — without it, a mapper that returned
nullfor everything would also pass.Confirmed the integration test actually catches the bug by reverting
jdbcGetand re-running: 2 of 3 fail withbigint NULL must not read as 0L ==> expected: <null> but was: <0>. The zero-value test correctly still passes.The example uses the
kt-localprocess plugin, sosqlc diffin CI exercises this branch's generator rather than the released WASM.Verification run
gofmt -l internal plugin— cleanmake test— passsqlc diff(v1.28.0, matching CI) — clean, no churn in existing golden filesexamples:./gradlew test— 15/15 across 8 classes, against Postgres 11 and MySQL 8api-v2: regenerate +compileKotlin— pass; targeted repository test asserting NULL →null— passgo vetreports 3 protobuf lock-copy findings; all 3 are pre-existing onmainand none are in code this PR touches.Two unrelated pre-existing bugs found on the way
Both were surfaced by the
nulltestexample and are out of scope here — the example is deliberately read-only to avoid them. Worth separate issues::oneemits invalid Kotlin. ASELECT nullable_col ... :onegenerates return typeLong??— the:onewrapper appends?to a type that is already nullable. Does not compile.Long?parameter emitsstmt.setLong(i, x), which does not compile. This is the write-side mirror of the bug fixed here and needssetNull/setObjecthandling.Rollout for
api-v2api-v2pins this plugin as a WASM release, not viasqlc/run-sqlc.sh:buildSrc/src/main/kotlin/co/nelo/api/GenerateSqlcConfigTask.kt:53—pluginUrl→.../releases/download/v1.4.0/sqlc-gen-kotlin.wasm, with a pinnedsha256alongside it.So adopting this needs a new tag with
sqlc-gen-kotlin.wasmattached, then bumping that URL and sha256.