Skip to content

Fix HTTP parameters: byte[] to String corruption and TimeOnly to Time/Time64 binding (#483) - #484

Merged
alex-clickhouse merged 7 commits into
mainfrom
polyglot/cs-http-param-bytearray-timeonly
Aug 4, 2026
Merged

Fix HTTP parameters: byte[] to String corruption and TimeOnly to Time/Time64 binding (#483)#484
alex-clickhouse merged 7 commits into
mainfrom
polyglot/cs-http-param-bytearray-timeonly

Conversation

@polyglotAI-bot

Copy link
Copy Markdown
Collaborator

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):

  1. byte[]String silently inserted the literal text System.Byte[]. The formatter had a dedicated byte[] arm for FixedString but not for String, so a byte[] bound to a String column fell through to the value.ToString() arm — producing the 13‑character string System.Byte[] instead of the payload. This was silent data corruption, not an error. The binary write path (Types/StringType.cs) already accepts string, byte[], ReadOnlyMemory<byte> and Stream, so the HTTP path was simply out of sync.

  2. TimeOnly could not be bound to Time/Time64 at all. TimeOnly does not implement IConvertible, so the integer-only Time fallback arm (Convert.ToInt32(...)) threw InvalidCastException, and Time64 hit the default: throw — even with an explicit {t:Time} hint. TypeConverter also had no TimeOnly reverse mapping, so no‑hint inference threw ArgumentOutOfRangeException. TimeSpan worked; TimeOnly was simply unbindable.

Changes

  • Formats/HttpParameterFormatter.cs — decode byte[] and ReadOnlyMemory<byte> to text for String/FixedString (mirrors the binary write path); format TimeOnly for Time and Time64 via TimeOnly.ToTimeSpan() (guarded #if NET6_0_OR_GREATER). New arms are ordered before the existing fall‑through arms, so string/TimeSpan/int behavior is unchanged.
  • Types/TypeConverter.cs — infer TimeOnly as Time64(7), matching the existing TimeSpanTime64(7) mapping (used only when no explicit type/hint is supplied).
  • Types/TimeType.cs / Types/Time64Type.cs — accept TimeOnly in the binary write coercion too, so the binary insert path and the HTTP parameter path stay consistent (per AGENTS.md: "consider both the binary read and write paths … as well as the HTTP parameter write path").

Test

New tests fail on main and pass with the fix (verified by stashing the source fix and re‑running: 16 failures → 0):

  • Formats/HttpParameterFormatterTests.cs — formatter output for byte[]/ReadOnlyMemory<byte>String/FixedString (scalar and quoted contexts) and TimeOnlyTime/Time64, plus contrast cases pinning that stringString and TimeSpanTime are unchanged.
  • SQL/SqlParameterizedSelectTests.cs — end‑to‑end DB round‑trips through the real AddParameter + ExecuteReaderAsync entry point: byte[]String returns "ABC", TimeOnlyTime/Time64 round‑trip ([RequiredFeature(Feature.Time)]).
  • Types/TimeTypeTests.cs / Types/Time64TypeTests.cs — binary write coercion of TimeOnly.
  • Types/TypeMappingTests.csTimeOnly infers as Time64(7).

Full surrounding suite (HttpParameterFormatter, TimeType, Time64Type, TypeMapping, SqlParameterizedSelect, ParameterFormatterIntegration): 2065 passed, 0 failed. No existing tests were weakened. Builds clean across net6.0/net8.0/net9.0/net10.0.

Scope note: Stream bound to String on the HTTP text‑parameter path is intentionally left out — a Stream is a one‑shot resource that belongs to the binary insert path, not a URL/form text parameter. FixedString length 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

  • Deterministic repro confirmed (16 new-test failures on main, 0 with the fix)
  • Root cause documented above
  • Fix targets the root cause (missing formatter arms + reverse mapping + binary coercion)
  • Tests fail without fix, pass with fix; contrast cases pin unchanged sibling behavior
  • No existing tests broken (2065 passed, 0 failed)
  • Convention compliance verified per AGENTS.md (parametrized/TestCaseSource tests, method+scenario+expected naming, CHANGELOG + RELEASENOTES updated, no public‑API surface change)

…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
Copilot AI review requested due to automatic review settings July 30, 2026 18:35
@polyglotAI-bot
polyglotAI-bot requested a review from mzitnik as a code owner July 30, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 targeting String/FixedString, ensuring UTF-8 payload text is sent instead of System.Byte[].
  • Add TimeOnly support for Time/Time64 across HTTP formatting, binary write coercion, and TypeConverter inference (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 TimeOnlyTime/Time64.
ClickHouse.Driver.Tests/Types/TimeTypeTests.cs Adds binary write tests for TimeOnlyTime.
ClickHouse.Driver.Tests/Types/Time64TypeTests.cs Adds binary write/read test coverage for TimeOnlyTime64.
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();

Comment on lines +96 to +97
case StringType or FixedStringType when value is byte[] bytes:
return quote ? Encoding.UTF8.GetString(bytes).Escape().QuoteSingle() : Encoding.UTF8.GetString(bytes).Escape();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...lickHouse.Driver/Formats/HttpParameterFormatter.cs 94.73% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Can we include test cases for byte[]s that are not valid UTF8 strings?

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

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
@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

Thanks @alex-clickhouse — both addressed in the latest push (7e316b6):

1. Non-UTF-8 byte[] test cases. Testing this surfaced that the original Encoding.UTF8.GetString(bytes) approach was itself lossy for binary payloads: invalid UTF-8 sequences collapse to the U+FFFD replacement char, so e.g. new byte[] { 0xFF } was stored as the 3 bytes EF BF BD — the same silent-corruption class this issue is about. I reworked the byte[] / ReadOnlyMemory<byte>String/FixedString arms to escape the raw bytes byte-for-byte into ClickHouse escaped-string text (printable ASCII verbatim, \/' backslash-escaped, every other byte as \xHH), matching the fidelity of the binary write path (Types/StringType.cs). I verified against ClickHouse 26.5 that the server decodes \xHH in String params on both the top-level param_x=… path and inside quoted composite contexts (e.g. Array(String) elements), so any byte sequence now round-trips losslessly.

Tests added:

  • a parametrized formatter test pinning the escaped output for empty, printable-ASCII, invalid-UTF-8 (single byte / sequence / mixed with printable), valid multibyte UTF-8, control + DEL, and quote/backslash payloads;
  • a ReadOnlyMemory<byte> non-UTF-8 case;
  • a live-server round-trip (ShouldRoundTripNonUtf8ByteArrayThroughStringParameter) asserting a non-UTF-8 byte[] comes back byte-identical via hex().

2. #if NET6_0_OR_GREATER. Removed — dropped the guards in the files this PR touches (both the ones it added and the now-inconsistent pre-existing DateOnly ones alongside them), since the project floors at net6.0.

(This also makes the earlier Copilot note about a double UTF-8 decode moot — that path no longer calls Encoding.UTF8.GetString at all.)

I also merged main in to clear a CHANGELOG/RELEASENOTES conflict from #476 / #482 landing; the net diff vs main is unchanged apart from the above.

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.
@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

Self-audit follow-up (4782437): dropped Format_TimeSpanBoundToTime_ReturnsFormattedTime.

It pinned TimeSpanTime, which this PR doesn't change — I'd added it as a "contrast" case. TestCases.cs:354-361 already covers Time and Time64(1|3|6) with TimeSpan values, and those flow through SqlParameterizedSelectTests into the same HttpParameterFormatter path against a live server — e.g. ShouldExecuteParameterizedCompareWithExplicitType("'5:25:05'::Time","Time",05:25:05) — which is strictly stronger than the unit assertion I removed.

The TimeOnly and byte[] cases stay; those are the new behavior and aren't covered anywhere else. HttpParameterFormatterTests 20/20 and SqlParameterizedSelectTests 1800/1800 green.

Same class of feedback you gave on #480 and #482, so I've written it into AGENTS.md in #487.

@alex-clickhouse

Copy link
Copy Markdown
Collaborator
  1. Could some of the new tests be replaced by TestCases() in a way that's clean/makes sense?
  2. Please make the comments more concise.
  3. Please make the changelog entry more concise.

@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

Thanks — all three addressed in 92b3cbc, plus the merge conflict with main resolved (merge commit 8b1e5e7; kept both sides' CHANGELOG/RELEASENOTES entries).

  1. TestCase() consolidation. In HttpParameterFormatterTests the four separate byte-payload tests (byte[]→String, byte[]→FixedString, ReadOnlyMemory→String, ReadOnlyMemory non-UTF-8) are folded into the existing escaping TestCaseSource — it now takes (value, clickHouseType), so one method covers both value kinds, both target types and all escaping cases. The two TimeOnly formatter tests became one [TestCase]-driven method (Time / Time64(3)), and the two DB-level TimeOnly round-trips likewise (ShouldExecuteParameterizedSelectWithTimeOnly). I left the quoted-composite test standalone (it exercises a different Format overload) and the byte-array round-trip standalone (different assertion shape — hex()), since parameterising those would have hurt readability rather than helped.

  2. Comments trimmed throughout — the formatter's byte[] arm comment and EscapeBytes doc are down to the essentials, and the test comments to one or two lines each.

  3. Changelog/release-notes entry rewritten as a single concise bullet.

Verified in the devbox against ClickHouse latest: clean net10.0 build, focused suite 2030 passed / 0 failed, and the consolidated cases all run (not skipped).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

@alex-clickhouse
alex-clickhouse merged commit e71b7e6 into main Aug 4, 2026
19 checks passed
@alex-clickhouse
alex-clickhouse deleted the polyglot/cs-http-param-bytearray-timeonly branch August 4, 2026 08:46
alex-clickhouse added a commit that referenced this pull request Aug 5, 2026
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.
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.

HTTP parameters: byte[] bound to String silently inserts "System.Byte[]", and TimeOnly cannot be bound to Time/Time64 at all

3 participants