Fix HTTP parameters: byte[] to String corruption and TimeOnly to Time/Time64 binding (#483) - #484
Conversation
…to Time/Time64 HTTP query parameters silently corrupted a byte[] bound to a String column (the literal text "System.Byte[]" was sent instead of the payload) and could not bind a TimeOnly to Time/Time64 at all (InvalidCastException on Time, the default throw on Time64), even with an explicit type hint. The HTTP parameter formatter now decodes byte[]/ReadOnlyMemory<byte> to text for String/FixedString (mirroring the binary write path in StringType) and formats TimeOnly for Time/Time64. TimeOnly is also accepted by the Time/Time64 binary write coercion so both paths stay in sync, and TimeOnly infers as Time64(7) (matching TimeSpan) when no type hint is given. Fixes: #483
There was a problem hiding this comment.
Pull request overview
This PR fixes two HTTP-parameter binding gaps in the ClickHouse .NET driver: preventing silent data corruption when binding byte[] to String, and enabling TimeOnly to bind to Time/Time64 (including type inference), bringing HTTP parameter formatting back in sync with the binary write path.
Changes:
- Fix HTTP parameter formatting for
byte[]/ReadOnlyMemory<byte>when targetingString/FixedString, ensuring UTF-8 payload text is sent instead ofSystem.Byte[]. - Add
TimeOnlysupport forTime/Time64across HTTP formatting, binary write coercion, andTypeConverterinference (Time64(7)). - Add unit + end-to-end tests covering the regression and confirming unchanged sibling behaviors.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| RELEASENOTES.md | Documents the user-visible fix for HTTP parameter binding and TimeOnly support. |
| CHANGELOG.md | Adds an unreleased changelog entry for issue #483. |
| ClickHouse.Driver/Formats/HttpParameterFormatter.cs | Adds HTTP parameter formatting support for byte[]/ReadOnlyMemory<byte> and TimeOnly. |
| ClickHouse.Driver/Types/TypeConverter.cs | Adds TimeOnly reverse mapping to infer Time64(7). |
| ClickHouse.Driver/Types/TimeType.cs | Enables binary write coercion from TimeOnly to seconds for Time. |
| ClickHouse.Driver/Types/Time64Type.cs | Enables binary write coercion from TimeOnly to TimeSpan for Time64. |
| ClickHouse.Driver.Tests/Formats/HttpParameterFormatterTests.cs | Adds formatter-level tests for byte[]/ReadOnlyMemory<byte> and TimeOnly. |
| ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.cs | Adds end-to-end parameterized select round-trip coverage for byte[]→String and TimeOnly→Time/Time64. |
| ClickHouse.Driver.Tests/Types/TimeTypeTests.cs | Adds binary write tests for TimeOnly→Time. |
| ClickHouse.Driver.Tests/Types/Time64TypeTests.cs | Adds binary write/read test coverage for TimeOnly→Time64. |
| ClickHouse.Driver.Tests/Types/TypeMappingTests.cs | Adds type mapping test asserting TimeOnly infers as Time64(7). |
Comments suppressed due to low confidence (1)
ClickHouse.Driver/Formats/HttpParameterFormatter.cs:100
- Same as the
byte[]arm:Encoding.UTF8.GetString(bytesMemory.Span)is executed twice in the conditional operator. Decode/escape once and then apply quoting to avoid duplicate work.
case StringType or FixedStringType when value is ReadOnlyMemory<byte> bytesMemory:
return quote ? Encoding.UTF8.GetString(bytesMemory.Span).Escape().QuoteSingle() : Encoding.UTF8.GetString(bytesMemory.Span).Escape();
| case StringType or FixedStringType when value is byte[] bytes: | ||
| return quote ? Encoding.UTF8.GetString(bytes).Escape().QuoteSingle() : Encoding.UTF8.GetString(bytes).Escape(); |
There was a problem hiding this comment.
Thanks for flagging — I checked this and there's no double decode/escape at runtime. C#'s conditional operator (c ? a : b) evaluates the condition and then exactly one of the two branch expressions, never both (this is a language-spec guarantee). So on each call Encoding.UTF8.GetString(bytes) and .Escape() execute once — the two branches differ only by the trailing .QuoteSingle(), so there's no extra work or allocation for large payloads. The same applies to the ReadOnlyMemory<byte> arm at line 99–100.
The duplication is only in source, and it's intentional: this shape mirrors the existing String/FixedString scalar arm a few lines below —
return quote ? value.ToString().Escape().QuoteSingle() : value.ToString().Escape();— so the new byte[] / ReadOnlyMemory<byte> arms stay consistent with the formatter's convention for the same type family. Leaving as-is.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Can we include test cases for byte[]s that are not valid UTF8 strings? |
|
We don't need #if NET6_0_OR_GREATER, we already only target .net 6+ |
… NET6 guards Per @alex-clickhouse's review on PR #484: - byte[] / ReadOnlyMemory<byte> bound to String/FixedString are now escaped byte-for-byte into ClickHouse escaped-string text (\xHH for non-printable / non-ASCII bytes) instead of being decoded through Encoding.UTF8.GetString. UTF-8 decoding was lossy for payloads that are not valid UTF-8 (invalid sequences collapse to U+FFFD), so a binary byte[] was silently corrupted — the same class of silent corruption the issue is about. The server decodes \xHH back to the exact byte (verified against ClickHouse 26.5), so any byte sequence now round-trips losslessly, matching the binary write path's fidelity. - Removed the now-dead #if NET6_0_OR_GREATER guards in the files this PR touches; the project floors at net6.0 so the guards were always-true no-ops. Tests: parametrized formatter cases pinning \xHH escaping for invalid-UTF-8, control, DEL, quote/backslash and valid-UTF-8-multibyte payloads; a ReadOnlyMemory<byte> non-UTF-8 case; and a live-server round-trip asserting a non-UTF-8 byte[] returns byte-identical via hex().
…m-bytearray-timeonly # Conflicts: # CHANGELOG.md # RELEASENOTES.md
|
Thanks @alex-clickhouse — both addressed in the latest push ( 1. Non-UTF-8 Tests added:
2. (This also makes the earlier Copilot note about a double UTF-8 decode moot — that path no longer calls I also merged |
Format_TimeSpanBoundToTime_ReturnsFormattedTime pinned behavior this PR does not change. TestCases.cs already binds TimeSpan to Time/Time64(N) through the same formatter against a live server, so the case only restated existing coverage.
|
Self-audit follow-up ( It pinned The Same class of feedback you gave on #480 and #482, so I've written it into AGENTS.md in #487. |
|
…m-bytearray-timeonly # Conflicts: # CHANGELOG.md # RELEASENOTES.md
|
Thanks — all three addressed in 92b3cbc, plus the merge conflict with
Verified in the devbox against ClickHouse |
main added 22 new Unreleased entries since this branch was cut. Each is now its own changelog.d/ fragment, extracted verbatim by line number rather than retyped, so the assembled Unreleased section reproduces main's exactly (as a set of lines; sorting by PR number reorders entries within their sections). New fragments, one per (PR, category): #390 improvements multidim blittable inserts #472 improvements per-scalar Span<byte> reads #484 fixes byte[]/TimeOnly HTTP parameters #485 fixes JSON strings under ReadStringsAsByteArrays #490 breaking raw results return compressed bytes #490 features AcceptEncoding response compression #490 improvements lz4 by default, HttpClient, errors, deflate #492 fixes HTTP response disposal #493 fixes Enum type declarations #494 fixes raw-stream double dispose #497 fixes GetSchema("Columns") restrictions #498 fixes JSON paths starting with setting names #503 fixes quoted JSON typed paths #504 fixes quoted Tuple/Nested element names #509 fixes {name:Type} scanner vs server lexer #511 fixes {name:Type} hints after a non-hint brace #513 fixes @name placeholders, heredocs, $ in names #390's entry was appended to the *released* v1.3.0 section on main (v1.3.0 shipped 2026-06-29), so it would have documented an unreleased change under a shipped version and never appeared in 1.4.0's notes. It moves to Unreleased as a fragment; the rest of v1.3.0 is byte-identical. RELEASENOTES.md regenerated with --sync-notes. `--check` passes, the solution builds, and the packed .nupkg's releaseNotes open on v1.3.0 with no Unreleased stub and no #390 bullet.
Description
Fixes #483.
Two value types that ClickHouse handles fine could not be bound as query parameters over the HTTP parameter path (
Formats/HttpParameterFormatter.cs):byte[]→Stringsilently inserted the literal textSystem.Byte[]. The formatter had a dedicatedbyte[]arm forFixedStringbut not forString, so abyte[]bound to aStringcolumn fell through to thevalue.ToString()arm — producing the 13‑character stringSystem.Byte[]instead of the payload. This was silent data corruption, not an error. The binary write path (Types/StringType.cs) already acceptsstring,byte[],ReadOnlyMemory<byte>andStream, so the HTTP path was simply out of sync.TimeOnlycould not be bound toTime/Time64at all.TimeOnlydoes not implementIConvertible, so the integer-onlyTimefallback arm (Convert.ToInt32(...)) threwInvalidCastException, andTime64hit thedefault:throw — even with an explicit{t:Time}hint.TypeConverteralso had noTimeOnlyreverse mapping, so no‑hint inference threwArgumentOutOfRangeException.TimeSpanworked;TimeOnlywas simply unbindable.Changes
Formats/HttpParameterFormatter.cs— decodebyte[]andReadOnlyMemory<byte>to text forString/FixedString(mirrors the binary write path); formatTimeOnlyforTimeandTime64viaTimeOnly.ToTimeSpan()(guarded#if NET6_0_OR_GREATER). New arms are ordered before the existing fall‑through arms, sostring/TimeSpan/intbehavior is unchanged.Types/TypeConverter.cs— inferTimeOnlyasTime64(7), matching the existingTimeSpan→Time64(7)mapping (used only when no explicit type/hint is supplied).Types/TimeType.cs/Types/Time64Type.cs— acceptTimeOnlyin the binary write coercion too, so the binary insert path and the HTTP parameter path stay consistent (perAGENTS.md: "consider both the binary read and write paths … as well as the HTTP parameter write path").Test
New tests fail on
mainand pass with the fix (verified by stashing the source fix and re‑running: 16 failures → 0):Formats/HttpParameterFormatterTests.cs— formatter output forbyte[]/ReadOnlyMemory<byte>→String/FixedString(scalar and quoted contexts) andTimeOnly→Time/Time64, plus contrast cases pinning thatstring→StringandTimeSpan→Timeare unchanged.SQL/SqlParameterizedSelectTests.cs— end‑to‑end DB round‑trips through the realAddParameter+ExecuteReaderAsyncentry point:byte[]→Stringreturns"ABC",TimeOnly→Time/Time64round‑trip ([RequiredFeature(Feature.Time)]).Types/TimeTypeTests.cs/Types/Time64TypeTests.cs— binary write coercion ofTimeOnly.Types/TypeMappingTests.cs—TimeOnlyinfers asTime64(7).Full surrounding suite (
HttpParameterFormatter,TimeType,Time64Type,TypeMapping,SqlParameterizedSelect,ParameterFormatterIntegration): 2065 passed, 0 failed. No existing tests were weakened. Builds clean acrossnet6.0/net8.0/net9.0/net10.0.Scope note:
Streambound toStringon the HTTP text‑parameter path is intentionally left out — aStreamis a one‑shot resource that belongs to the binary insert path, not a URL/form text parameter.FixedStringlength is validated server‑side for HTTP text params (only the binary path writes fixed‑width bytes), so no client‑side length check is added.Pre-PR validation gate
main, 0 with the fix)AGENTS.md(parametrized/TestCaseSourcetests, method+scenario+expected naming, CHANGELOG + RELEASENOTES updated, no public‑API surface change)