Skip to content

fix(query): NULL-safe columnValue for cached-type default branch - #12

Open
totalslacker wants to merge 1 commit into
jkrukowski:mainfrom
totalslacker:upstream/columnvalue-null-safety
Open

fix(query): NULL-safe columnValue for cached-type default branch#12
totalslacker wants to merge 1 commit into
jkrukowski:mainfrom
totalslacker:upstream/columnvalue-null-safety

Conversation

@totalslacker

Copy link
Copy Markdown

Summary

  • Two-line NULL-safety fix in Database.columnValue's default: branch: guard the return of sqlite3_column_text instead of force-unwrapping via UnsafePointer(nil!).
  • Two regression tests in DatabaseTests.swift.

Why

Database.query(_:) caches column types once from the first row and reuses them for every subsequent row. A nullable TEXT column whose first row has a value and later row is NULL dispatches the NULL cell into columnValue's default: branch (as SQLITE_TEXT) rather than case SQLITE_NULL:. That branch calls String(cString: UnsafePointer(sqlite3_column_text(stmt, index)))sqlite3_column_text returns nil for a genuinely-NULL cell, and UnsafePointer(nil)! traps with Unexpectedly found nil while implicitly unwrapping an Optional value.

Reproducible any time a SELECT returns a value-then-NULL sequence in the same nullable TEXT column across multiple rows. Not a corner case — this is the shape of any typical SELECT * FROM t where a nullable TEXT column has mixed content.

Fix shape

Return nil from columnValue when sqlite3_column_text returns nil — matches the case SQLITE_NULL: branch's semantics (row dictionary omits the key). Alternative considered — re-read sqlite3_column_type per-row inside the loop — would structurally prevent the same class of bug in the other type-switch branches; noted in the code comment but skipped here as a scope-widening change. Happy to include the per-row re-read if maintainer prefers.

Test plan

  • swift test --filter DatabaseTests — 12/12 pass in 0.021s locally on Apple Silicon macOS 14
  • New testNullableTextColumnAcrossRowsDoesNotCrash — the fix is the delta between "trap" and "pass"
  • New testNullFirstRowLosesValueInSecondRow — locks in the pre-existing "NULL-first-row-loses-later-value" behaviour (distinct bug from what this PR targets; documented so a future per-row-type change knows what it needs to fix)

Downstream context

Surfaced by a downstream consumer (SkippedPageStore in a private app) hitting the crash in production. Full crash log available on request. Fix has also been landed on a downstream fork (totalslacker/SQLiteVec#1) so the affected app can ship before upstream review latency; this PR is the equivalent contribution back to the source of truth.

Database.query(_:) captures column types once from the first row and
reuses them across the whole result set. A nullable TEXT column whose
first row has a value and later row is NULL sends the NULL cell into
columnValue's `default:` branch (as SQLITE_TEXT), which called
`String(cString: UnsafePointer(sqlite3_column_text(stmt, index)))` —
force-unwrapping a nil return, trapping with
"Unexpectedly found nil while implicitly unwrapping an Optional value".

Guard the pointer and return nil, matching the SQLITE_NULL case's
semantics (row dictionary omits the key). Two regression tests added,
one for the crashing shape and one documenting the pre-existing
"NULL-first-row-loses-later-value" behavior so this fix doesn't
regress it.

Reported downstream at totalslacker/WebBrain#361 (Dashboard timeline
crash) and totalslacker/SemanticHistory#173 (pin-bump for the fix).
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.

1 participant