Skip to content

TCP N11: query parameter binding — {name:Type} values over the native protocol - #561

Draft
alex-clickhouse wants to merge 14 commits into
tcp/epic-m-connection-poolfrom
tcp/epic-n11-query-parameters
Draft

TCP N11: query parameter binding — {name:Type} values over the native protocol#561
alex-clickhouse wants to merge 14 commits into
tcp/epic-m-connection-poolfrom
tcp/epic-n11-query-parameters

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #560 (tcp/epic-m-connection-pool) — review that one first; this PR's diff is the parameters only.

TCP epic N11: bind values to a parameterized query's {name:Type} placeholders. The Query packet has carried a parameter-list field since L4, but the client always sent it empty.

await using var client = new ClickHouseTcpClient("Host=localhost");

var options = new ClickHouseTcpQueryOptions
{
    Parameters = new ClickHouseTcpParameterCollection
    {
        { "since", new DateOnly(2026, 1, 1) },
        { "tags",  new[] { "a", "b" } },
    },
};

await foreach (object[] row in client.QueryAsync(
    "SELECT * FROM events WHERE day >= {since:Date} AND tag IN {tags:Array(String)}", options))
{
}

No method signature changed — every overload already took an options object, so only four internal parameters: null call sites moved. InsertAsync inherits Parameters from the same record, so INSERT INTO t SELECT ... WHERE x > {t:Int32} works with no extra plumbing.

The wire contract is not what the spec said

This is the part worth reading. Our native-protocol spec note (ignored-docs/tcp/native-protocol.md, not in the repo) claimed that String parameters must arrive single-quoted. The truth is broader and sharper, and I established it by probing ~60 cases against ClickHouse 26.6.1 on both transports before writing any code.

Every type must be quoted. The parameter rides the settings list with flags = 0x02 (Custom), so the server runs Field::restoreFromDump on the text before the {name:Type} substitution ever sees it. An unquoted 42 for an Int32 parameter fails with Couldn't restore Field from dump: 42, exactly as a bare Alice does for a String.

The value therefore crosses two unescape stages, and stage 2 turns out to be the same escaped-text reader that HTTP's param_<name> feeds — I confirmed that by curling the HTTP endpoint and getting byte-identical results. So the rule is:

wire value = QuoteSingle(Escape(T))     where T is the exact text HTTP sends

To send the three characters a\b the wire carries 'a\\\\b'. A literal newline must travel as 'a\\nb', because stage 2 terminates on a raw newline or tab.

Two server behaviours here are silent rather than diagnosed, and both corrupt data:

  • Doubling a quote does not escape it. 'a''b' restores as a, no error. Only backslash escaping works.
  • Trailing text after the closing quote is ignored. 'ab' XYZ restores as ab.

Neither is reachable through this API — the formatter owns the quoting — but they are why the escaping is not negotiable, and why ParameterText.Escape is applied twice rather than once. The spec note and the L4 TODO entry are corrected.

Decisions worth a look

The formatter is a deliberate duplicate, and that is the uncomfortable part. Since the inner text must match HTTP byte for byte, the ideal is one implementation. The assembly graph forbids it: ClickHouse.Driver already references ClickHouse.Driver.Tcp, so a reference back is circular, and HttpParameterFormatter is written against the RowBinary ClickHouseType tree, which this assembly does not have. Sharing would mean moving ClickHouseType, TypeConverter and TypeSettings into ClickHouse.Driver.Common while keeping the net6.0 HTTP build green — an epic, not a PR. So TcpParameterFormatter mirrors HttpParameterFormatter arm for arm, and nothing enforces that today. A CI parity test (one shared case table through both formatters, asserting tcp == http.Escape().QuoteSingle()) is filed as TODO N11a rather than built here. It is also the precondition for ever collapsing the two.

Five defects found by comparing against the other two implementations. I first wrote this port to reproduce two known HTTP bugs on the grounds that parity was the invariant. That was the wrong call — parity with a bug is still a bug — so they are fixed, and I then went looking for more by diffing this formatter's behaviour against the HTTP test corpus and against clickhouse-go's native parameter tests.

They are fixed on the TCP side only. This PR touches nothing outside ClickHouse.Driver.Tcp and its tests: HTTP is a shipped transport, and changing its behaviour does not belong in a TCP epic PR. An earlier revision of this branch did fix them there too; that is reverted.

Two are inherited HTTP defects:

  • A byte[] bound to a String parameter was sent as the literal text System.Byte[]. Only FixedString read the bytes. Silent corruption, and reachable in practice since readStringsAsByteArrays makes that a plausible round-trip.
  • Interval<Unit> threw. TCP now sends the underlying Int64 count — the server accepts it, so this was purely a missing arm. HTTP could not be fixed the same way regardless: the RowBinary tree has no Interval type at all, so an HTTP query fails while resolving the name, before any formatter runs. Adding it is a feature with a read path and a PublicAPI change (there is already a TODO: following interval implementation at Types/BinaryTypeDecoder.cs:136).

Three more were this port being behind HTTP, which the comparison is what surfaced:

  • {p:Json} threw — the arm matched only the uppercase JSON, though the server takes either spelling and reports both as JSON.
  • QBit(T, N) had no arm.
  • The six geo names had no arm (Point, Ring, LineString, Polygon, MultiLineString, MultiPolygon). They now format as the shape they stand for, mirroring HTTP, where PointType : TupleType and the rest are ArrayType.

All eight of those types now have live round-trips.

Type resolution has three rungs: the parameter's own ClickHouseType, then the query's {name:Type}, then the CLR value. The middle rung is a port of the HTTP SqlParameterTypeExtractor, so comments and string literals are skipped. The third rung looks redundant — a query the server can run must declare the type anyway — and it nearly is. It exists so that a parameter the query does not name, or one whose hint the scanner did not see, is harmless rather than fatal. Throwing there would have broken reusing one collection across queries.

Deliberately out of scope: ADO-style @name rewriting (this client is not an ADO provider; revisit if a TCP-backed DbConnection lands) and the IParameterFormatter / IParameterTypeResolver hooks (both live in the HTTP assembly and cannot be referenced from here).

No changelog entry, and no diff outside the TCP projects. No commit in this epic series has touched CHANGELOG.md, since the TCP client is unreleased and [Experimental], and that holds here. Say the word if you want the whole epic summarised there when it lands.

The two formatters now diverge in eight places, all recorded in N11a. Six are HTTP defects that this port fixes only on its own side; two of those silently corrupt data (byte[] as String, and a byte[] that is not valid UTF-8). The TODO carries a table of what HTTP does today and what fixing each would take, so the parity check has something concrete to settle rather than a switch to flip. Fixing them is a separate PR against main, for whoever wants to own that behavioural change.

A silent timezone defect, now refused rather than sent. A bare {d:DateTime} hint declares no timezone, so the server reads the wall-clock text in session_timezone. The client wrote a kinded DateTime/DateTimeOffset as UTC, so on a non-UTC session the instant moved — nine hours for Asia/Tokyo — with no error. The client now throws instead, naming the value's kind, what the server would have done, and both ways out:

Parameter 'd' (type DateTime): A DateTimeOffset names an instant, but the type declares no timezone, so the instant cannot be sent without loss. The server reads the value in its session timezone, which moves the instant when that is not UTC, and reports no error. Declare the timezone in the type — DateTime('UTC') — or pass a DateTime with Kind=Unspecified to send a wall-clock time for the server to read in its own timezone.

Kind=Unspecified with a bare hint stays legal, and that limit is the point. Unspecified is a wall clock with no instant attached, which is exactly what a type with no timezone carries — nothing is lost and the round-trip is faithful. Only a value that names an instant is refused, because only then does the client hold information the wire cannot carry.

Epoch was the first candidate and the evidence killed it. clickhouse-go sends epoch seconds, which is immune to session_timezone — I confirmed that. But it cannot carry the range: the server reads an integer of four digits or fewer as a year, so epoch 09999 (1970-01-01 00:00:00 to 02:46:39 UTC) is rejected with value 1000 cannot be parsed as Date, as is any negative epoch for DateTime. An encoding with a hole in it is not an encoding. The other candidate — formatting into the session timezone — needs the client to guess server state at format time and go silently wrong when the guess is stale. Refusing beat guessing.

HTTP keeps the old behaviour, deliberately. The distinction is not timidity: on HTTP this combination is correct today for everyone on a UTC server, which is the default. Throwing there would break working code for the majority to protect the non-UTC minority, and it is a shipped client. TCP is unreleased and [Experimental], so establishing "declare your timezone" costs nobody. Filed as N11d-http with three graded options (log a warning / opt-in switch / throw), and a note that the kinded half has no HTTP test at all — that should be written first, because it fails silently today.

Security

The Identifier arm is the only one that emits its value unescaped, so I checked what it can reach. The server substitutes it as a single AST identifier and applies its own backtick quoting, so it cannot break out:

value for {c:Identifier} result
a) UNION ALL (SELECT 999 UNKNOWN_IDENTIFIER, treated as one name
a`,`b escaped to a\`,\`b, one name
system.tables one name — it cannot even cross a database

Ordinary values are inert too: ' OR 1=1 -- round-trips as data, covered by a test at both the formatter and the live-server level. An Identifier parameter is still a "trusted name" surface in the same sense it is on HTTP — it selects a name, so a caller must not let an untrusted value choose which column is read — but it cannot inject syntax.

Testing

2462 tests pass, 101 of them live-server round-trips covering every supported parameter type. Coverage on the new files: formatter 98%, extractor 98%, collection 100%, inference 87%.

The escaping expectations are hand-derived from the two-stage rule and then confirmed against a real server, so the unit and integration layers check each other rather than restating one belief twice. The character-class cases are the ones I would keep: a carriage return and a NUL need no escape while a newline and a tab do, which is easy to get wrong in either direction.

The corner cases came from reading the other two implementations' suites rather than from imagination. I inventoried what ClickHouse.Driver.Tests binds as an HTTP parameter and what clickhouse-go binds as a native one, then took everything either had that this did not. The additions a live server can settle:

  • NaN, +Infinity, -Infinity — .NET and ClickHouse spell these differently and the server happens to accept both, which only a round-trip shows;
  • a Bool inside a composite — the server takes 1/0 for a scalar Bool but rejects them for Array(Bool), so a formatter emitting digits passes the scalar case and fails this one (clickhouse-go hit exactly this, issue #1891);
  • the Date and Date32 range ends, a pre-epoch DateTime64, and scale 9;
  • an Enum by number rather than by label; decimals of 30 and 50 digits, past what a CLR decimal holds;
  • negative and past-24h Time/Time64; a surrogate pair; a nine-element ValueTuple (TRest nesting);
  • four more Map shapes — empty, a key needing escapes, a non-string key, one holding an array.

Non-zero-lower-bound and zero-length array axes are unit tests, being shapes no server round-trip can produce.

The suite can now gate on server version, which QBit forced: it reached the matrix and failed on 25.8, which predates the type. TcpFeature carries a [SinceVersion] per capability and TcpServerFeatures maps a version onto the set — the same shape as Feature and ClickHouseFeatureMap in the HTTP suite, kept as a separate copy because the TCP projects do not reference ClickHouse.Driver, which is what keeps the TCP CI job building only the two TCP projects. Whole tests use [RequiresServerFeature]; a single case of a TestCaseSource guards its yield, since a case that is never yielded cannot carry an attribute. The version comes from CLICKHOUSE_VERSION rather than from the server, because a case source is enumerated during discovery, before the fixture has started the container. A moving tag resolves to "everything supported", so a real gap fails loudly instead of being skipped in silence. The mapping has its own tests — a wrong answer there does not fail a test, it skips one.

The comparison also found gaps in the HTTP suite that I did not fix here, filed as N11c: no NaN/Infinity parameter anywhere, no upper-bound dates (only the lower bound is ever tested), no DST-gap or DST-overlap instant, and Time/Time64 commented out of the composite matrix. Each is two tests, and the HTTP half may well find something.

Second review round

Six inline comments, all valid against current HEAD — none of them outdated. Four defects, plus two of my own tests that passed against the code they were meant to guard:

  • The SQL scanner ignored \'. ClickHouse accepts a backslash-escaped quote in a string literal, so a literal holding one looked closed and the placeholder after it was skipped — the type then fell back silently to CLR inference.
  • Variant alternatives were matched on the outer type name alone, so a string[] took the first arm of Variant(Array(Int32), Array(String)) and every element went out unquoted. Matching now recurses into element, key/value and tuple element types, and checks tuple arity.
  • A Map read back from this client could not be bound again. The codec surfaces a row as KeyValuePair[] so duplicate keys and order survive, and only IDictionary was accepted.
  • byte[] was decoded as UTF-8, so 0xFF became U+FFFD and the server stored EF BF BD. Probing showed \xHH in the inner text yields the exact byte, so bytes that are not valid UTF-8 now travel that way while valid UTF-8 keeps the readable form.
  • The InsertAsync test called ExecuteAsync. A dropped parameters argument in either real overload would have passed the whole suite. Both are covered now, through a parameterized target.
  • The one-shot sequence test was doubly vacuous: its SQL carried a hint, so inference never ran, and an iterator method yields a fresh enumerator per enumeration, so it was not one-shot either. Coverage moved to BuildParameters, where SQL that does not name the parameter reaches the inference pass.

Since the sharpest finding was "this test passes on the unfixed code", I reverted all four source fixes and re-ran. Fifteen new tests failed, as they should — and two of mine passed, so I had repeated the mistake while fixing it. One put the literal after the placeholder, so the scanner never entered a string on the way to the hint; with that corrected it still passed, because an even number of escaped quotes makes the mis-scan open and close the same number of times and land outside a string again. The third attempt showed the difference is not observable through the server at all, so that test is gone and a comment points at the unit tests, which are true negatives. Every new test here has been run against the unfixed code and fails there.

Coverage then flagged that the Variant pair-sequence arm had no test at all, and that a try/catch (FormatException) I had added around NamedElementParser.Split can never fire — the exception comes from ElementTypeStrings, which validates arity. Both fixed; ParameterTypeInference 82% → 95%.

Review outcomes

This is the earlier review pass, before the cross-implementation comparison above. Five defects found and fixed. Four were parity divergences, and one of those was silent:

  • A byte[] in a Variant became the text System.Byte[]. It matched a string alternative before an array one, and the string arm prints the CLR type. HTTP picks Array(UInt8) via CanWrite. This was the only silent corruption found — everything else failed loudly.
  • Time64 rounded away from zero where HTTP pre-rounds to even, so a midpoint landed one tick high.
  • A decimal given as text rejected the exponent, thousands-separator and accounting-parentheses forms HTTP accepts.
  • A Variant error named a parameter the caller never wrote, because alternative matching inferred under a placeholder name.
  • Type inference consumed a one-shot sequence: it read the sequence to find the element type and formatting read it again, so a LINQ chain or iterator arrived empty. Copied before the first read now.

Plus the multidimensional-array depth check I found myself before the review landed: the arm peeled the declared nesting only as far as the CLR rank, so a rank-3 array declared Array(Array(T)) emitted three bracket levels and only the server noticed.

One trap the client cannot fix, and CI corrected my understanding of it. A parameter named after a server setting can be applied as that setting, because the native protocol carries parameters in the settings list. limit and offset are the names callers reach for, and the failure is a parse error about a quoted string that names neither the parameter nor the cause.

I first wrote this up as a fixed protocol limitation, on the evidence of my local 26.6.1. The matrix disagreed: it is version-dependent and already fixed upstream — 25.8 through 26.6 reject the query, latest binds correctly. My local server happened to sit on the broken side, so nothing short of the matrix would have shown it.

The test therefore pins the property that holds on every version rather than one version's error: such a name must never bind to the wrong value. Either the server rejects the query, or the count is right; a wrong count fails. That is the outcome that would actually hurt, and it is a stronger assertion than the one it replaced. The doc now says "rename the parameter if you support any server in 25.8–26.6" instead of claiming the protocol forbids it. Unaffected on HTTP, which carries the name separately.

The review also confirmed the parts I most wanted checked: the two-stage escaping held against every adversarial input it tried (BEL/BS/FF/VT, an already-quoted value, a 200 000-character value, literal \x41 and \N sequences, emoji), the Identifier arm is contained by the outer escape, and the extractor is a faithful port.

🤖 Generated with Claude Code

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

Adds native TCP binding for ClickHouse {name:Type} query parameters.

Changes:

  • Adds parameter APIs and query/insert plumbing.
  • Implements type extraction, inference, formatting, and wire escaping.
  • Adds protocol, unit, and integration coverage.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
TcpParameterFormatter.cs Formats parameter values for native transport.
SqlParameterTypeExtractor.cs Extracts type hints from SQL.
ParameterTypeInference.cs Infers types from CLR values.
ParameterText.cs Implements two-stage escaping.
IClickHouseTcpClient.cs Documents parameter support.
ClickHouseTcpQueryOptions.cs Exposes per-query parameters.
ClickHouseTcpParameterCollection.cs Adds ordered parameter collection.
ClickHouseTcpParameter.cs Defines a bound parameter.
ClickHouseTcpClient.cs Forwards formatted parameters.
QueryPacketTests.cs Verifies wire encoding.
TcpParameterFormatterTests.cs Tests standard formatting.
TcpParameterFormatterEdgeCaseTests.cs Tests formatter edge cases.
SqlParameterTypeExtractorTests.cs Tests SQL hint extraction.
ClickHouseTcpParameterCollectionTests.cs Tests collection and resolution.
ClickHouseTcpParameterIntegrationTests.cs Adds live-server coverage.
Suppressed comments (1)

ClickHouse.Driver.Tcp/Parameters/SqlParameterTypeExtractor.cs:147

  • The same scanner bug occurs inside quoted type arguments: valid backslash-escaped enum labels are terminated at \', so a } in the label can truncate the extracted type and make TypeParser.Parse reject a query the server accepts. Handle backslash escapes here too; TypeTokenizer already treats them opaquely.
                // An escaped quote ('') stays inside the string.
                if (c == '\'' && i + 1 < sql.Length && sql[i + 1] == '\'')
                {
                    i += 2;
                    continue;

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ClickHouse.Driver.Tcp/Parameters/SqlParameterTypeExtractor.cs
Comment thread ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs
Comment thread ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs
Comment thread ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs Outdated
Comment thread ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpParameterIntegrationTests.cs Outdated
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (6)

ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs:102

  • This does not actually make an array alternative take precedence for byte[]: Variant(String, Array(UInt8)) accepts the first String arm and decodes the bytes as UTF-8, whereas the HTTP CanWrite path selects Array(UInt8) regardless of ordering. The existing test only uses Variant(Array(UInt8), String), so it misses the corrupting order. Prefer an array-compatible alternative for byte[] before falling back to String/FixedString.
            // A byte array reads as text or as an array of bytes, so an Array alternative takes it first. The
            // string arms would otherwise win and print the CLR type name instead of the contents.
            byte[] => node.Name is "Array" or "String" or "FixedString" ? node.Name : "String",

ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpParameterIntegrationTests.cs:283

  • This test supplies {p:Array(Int32)}, so BuildParameters finds a type hint and never calls Materialize or ParameterTypeInference.Infer; the iterator is only enumerated once and the test passes without the one-shot-sequence fix. Add a unit regression that calls BuildParameters with an unreferenced parameter (for example SQL SELECT 1) and asserts the inferred/formatted wire value.
        object value = await ScalarAsync(client, "SELECT toString({p:Array(Int32)})", options);

ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpParameterIntegrationTests.cs:421

  • Despite the test name, this invokes ExecuteAsync, so it only covers the query path and none of the three newly wired InsertAsync call sites. Exercise an actual InsertAsync overload with parameters (for example a {target:Identifier} table placeholder) so a regression that drops parameters from inserts fails.
            await client.ExecuteAsync($"INSERT INTO {table} SELECT number FROM numbers(5) WHERE number > {{threshold:Int32}}", options, None);

ClickHouse.Driver.Tcp/Parameters/SqlParameterTypeExtractor.cs:42

  • ClickHouse also supports backslash-escaped quotes in SQL strings. For a valid literal such as 'it\'s {p:Date}', this scanner treats the escaped quote as the end of the string and extracts the placeholder-looking text inside it; a later real {p:Int32} can then be skipped or reported as conflicting. Skip a backslash and its escaped character in both quote-scanning loops, and add a regression case.
                // An escaped quote ('') stays inside the string.
                if (c == '\'' && i + 1 < sql.Length && sql[i + 1] == '\'')

ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs:506

  • Round the total duration before splitting it into hours/minutes/seconds. At a carry boundary such as 00:00:59.9995 for Time64(3), this currently emits 0:00:60.000, whose seconds component is outside ClickHouse's 00..59 range, instead of 0:01:00.000.
        // Round before formatting, and to even, because decimal.ToString rounds away from zero. Without this a
        // midpoint lands one tick above where the HTTP formatter puts it.
        string secondsText = Math.Round(seconds, scale, MidpointRounding.ToEven)
            .ToString("00." + new string('0', scale), CultureInfo.InvariantCulture);

ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs:106

  • TimeSpan is omitted from the shared-CLR-type cases even though the formatter accepts it for both Time and Time64. Consequently a valid value for Variant(Time, String) is rejected as having no matching alternative because fallback inference only returns Time64. Match TimeSpan against either time type, as the HTTP CanWrite path does.
            DateTime or DateTimeOffset => node.Name is "DateTime" or "DateTime64" or "Date" or "Date32" ? node.Name : "DateTime64",

@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch 2 times, most recently from 01afec8 to 07c59bd Compare August 16, 2026 15:36
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 07c59bd to f916fb2 Compare August 16, 2026 17:51
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from f916fb2 to 82b87ca Compare August 17, 2026 09:49
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 82b87ca to 7877a08 Compare August 18, 2026 06:49
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 7877a08 to ceeb8ba Compare August 18, 2026 07:03
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from ceeb8ba to 261d2fa Compare August 18, 2026 07:29
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 261d2fa to 8d3a032 Compare August 18, 2026 08:11
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 8d3a032 to 0beee01 Compare August 21, 2026 13:07
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 0beee01 to 011fe37 Compare August 22, 2026 16:47
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 011fe37 to 1ff4134 Compare August 22, 2026 17:06
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 1ff4134 to 3b4f066 Compare August 22, 2026 17:25
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 3b4f066 to df6b1e1 Compare August 26, 2026 08:59
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from df6b1e1 to 6ce3520 Compare August 28, 2026 11:00
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n11-query-parameters branch from 6ce3520 to 2a1ee00 Compare August 28, 2026 12:18
alex-clickhouse and others added 14 commits August 30, 2026 11:05
Bind values to a parameterized query's {name:Type} placeholders over the
native protocol. ClickHouseTcpQueryOptions gains a Parameters collection;
the client resolves each value's type, formats it, and fills the Query
packet's parameter list, which until now was always empty.

The wire contract is not the one the spec described. A parameter rides the
settings list with the Custom flag, so the server restores it as a Field
before the {name:Type} substitution reads it. The value therefore crosses
two unescape stages and must arrive as a quoted SQL literal for every type,
not only for String -- an unquoted 42 for an Int32 parameter is rejected.
The value text is escaped once for the substitution stage, then escaped and
quoted again for the Field stage. Doubling a quote instead of escaping it
truncates the value with no error, so only backslash escaping is used.

The inner text is the same SQL representation the HTTP transport sends, so
TcpParameterFormatter mirrors HttpParameterFormatter arm for arm. It cannot
call it: ClickHouse.Driver already references ClickHouse.Driver.Tcp, and the
HTTP formatter is written against the RowBinary type tree this assembly does
not have. A CI parity check between the two is left as a follow-up.

The type comes from the parameter's own ClickHouseType, else the query's
{name:Type} placeholder, else the value's CLR type. The last rung only
carries a parameter the query never names, because a named one must declare
its type for the server anyway.

Co-Authored-By: Claude <noreply@anthropic.com>
A carriage return and a NUL cross both server stages as themselves, unlike a
newline and a tab, which end the substitution stage's reader. The escape set
is easy to widen or narrow by accident, so round-trip both, along with a
value ending in a backslash.

Also say in the collection's own docs that it is mutable and not thread-safe,
since the client it is handed to is meant to be shared.

Co-Authored-By: Claude <noreply@anthropic.com>
A byte array bound to a Variant matched a string alternative before an array
one, and the string arm prints the CLR type, so the value silently became
"System.Byte[]" instead of its contents. Array alternatives now take a byte
array first.

Matching a Variant alternative inferred with a placeholder parameter name, so
an unmappable value reported advice about a parameter the caller never wrote.
The failure to map is now simply a failure to match, and the Variant reports
itself.

Time64 relied on decimal.ToString, which rounds away from zero, putting a
midpoint one tick above where the HTTP formatter puts it. It now rounds to
even first, as HTTP does.

A decimal given as text rejected the exponent, thousands-separator and
accounting-parentheses forms that HTTP accepts. It now reads through decimal
first and keeps the wide-value path for what decimal cannot hold.

Type inference read a sequence to find its element type and formatting read
it again, so a sequence readable only once arrived empty. It is copied before
the first read.

Also documents the one trap the protocol imposes and the client cannot fix: a
parameter named after a server setting is applied as that setting, so "limit"
and "offset" fail with a parse error that names neither the parameter nor the
cause. Both halves are pinned by tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Coverage of the new code: TcpParameterFormatter 72% -> 98%,
ParameterTypeInference 29% -> 87%, the parameter collection to 100%.

The added cases are the ones no per-type round-trip reaches: types the
server never sends back as themselves (Nothing, JSON, Nested, Time64), the
CLR shapes that reach one type by more than one route (FixedString from
bytes, Date from a DateTime, Tuple from a list), the error paths, and the
DateTime64(precision, timezone) argument order, where the precision digit
must not be read as a timezone.

Co-Authored-By: Claude <noreply@anthropic.com>
The HTTP formatter resolves a rank>1 array's leaf type by peeling the whole
declared nesting and then checking the depth equals the CLR rank, naming
both when they disagree. The port peeled only as far as the rank, so the
two depths were never compared.

A rank-3 array declared Array(Array(T)) then emitted three bracket levels
for a two-level type and only the server rejected it. The other direction
reached a type arm with the wrong nesting level and failed there, with a
message about the value rather than about the depth.

Co-Authored-By: Claude <noreply@anthropic.com>
CI showed the collision is version-dependent, not a fixed protocol trait.
ClickHouse 25.8 through 26.6 apply a parameter named "limit" as the setting
and reject the query; newer servers bind it correctly. The test asserted the
older behaviour unconditionally, so it failed on latest.

It now pins the property that holds on every version: such a name must never
bind to the wrong value. Either the server rejects the query or the count is
right; a wrong count fails. Also covers "offset", and corrects the doc, which
called this a limit of the protocol rather than a server bug since fixed.

Co-Authored-By: Claude <noreply@anthropic.com>
The TCP formatter is a port of the HTTP one, so it inherited two defects.
Parity was the wrong invariant for these: both are plain bugs.

A byte[] bound to a String parameter reached value.ToString() and sent the
text "System.Byte[]". Only FixedString read the bytes. The value is now
decoded as UTF-8 and escaped on both transports. This is a behavioural
change to the shipped HTTP client, so it is in the changelog.

Interval<Unit> had no arm and threw. TCP now sends the underlying Int64
count, which the server reads. HTTP keeps throwing, but earlier and for a
different reason: the RowBinary type tree has no Interval type, so the
query fails while resolving the name. Adding it there is a feature, not a
parity fix, and stays in the TODO.

Co-Authored-By: Claude <noreply@anthropic.com>
Compared this formatter against the HTTP suite's case table and against
clickhouse-go's native parameter tests. Both found types this one rejects
and the other two accept.

Json in its lowercase spelling: the arm matched only "JSON", though the
server takes either and reports both as JSON. QBit and the six geo names
had no arm at all. Geo formats as the shape it stands for, matching HTTP,
where Point subclasses Tuple and the rest subclass Array.

The new cases are the ones a live server can settle: NaN and the two
infinities, a Bool inside a composite (the server takes 1/0 for a scalar
Bool but not for an Array(Bool)), the Date and Date32 range ends, a
pre-epoch DateTime64, an Enum by number, decimals of 30 and 50 digits,
negative and past-24h durations, a surrogate pair, a nine-element tuple,
and four more Map shapes. Non-zero-lower-bound and zero-length array axes
are unit tests, being shapes a round-trip cannot produce.

Two of them pin a timezone foot-gun this transport shares with HTTP: a
bare {d:DateTime} hint carries no timezone, so the server reads the text
in session_timezone while the client wrote it in UTC, and the instant
moves. Declaring the timezone in the hint is correct and is pinned beside
it. Pinned rather than fixed, because the HTTP suite documents the same
trap and changing it is a semantic break for both.

Co-Authored-By: Claude <noreply@anthropic.com>
QBit reached the matrix and failed on 25.8, which predates the type. The
suite had no way to say so, so the case was the first thing to need one.

TcpFeature carries a [SinceVersion] per capability and TcpServerFeatures
maps a version onto the set, the same shape as Feature and
ClickHouseFeatureMap in the HTTP suite. It is a separate copy because the
TCP projects do not reference ClickHouse.Driver, which is what keeps the
TCP CI job building only the two TCP projects.

The version comes from CLICKHOUSE_VERSION rather than from the server. A
TestCaseSource is enumerated during discovery, before the fixture starts
the container, so there is nothing to ask at the point the answer is
needed. CI always sets the variable, which is where the gating has to be
right. A moving tag resolves to All, so a real gap fails loudly instead
of being skipped in silence.

Whole tests use [RequiresServerFeature]; one case of a TestCaseSource
guards its yield, as QBit now does. The mapping has its own tests: a
wrong answer here does not fail a test, it skips one.

Co-Authored-By: Claude <noreply@anthropic.com>
A DateTime of Kind Utc or Local, and any DateTimeOffset, names a point in
time. The wire carries a wall-clock time and no timezone, so the server
reads it in session_timezone. The client wrote it in UTC, so the instant
moved by the session offset and nothing reported it: nine hours for
Asia/Tokyo. It now throws, and the message names the value's kind, what
the server would do, and both ways out.

Kind=Unspecified with a bare hint stays legal. It is a wall-clock time
with no instant attached, which is what a type with no timezone carries,
so nothing is lost and the round-trip is faithful.

Epoch was tried first, as clickhouse-go does it, and rejected on
evidence: the server reads an integer of four digits or fewer as a year,
so epoch 0 through 9999 fails outright, as does any negative epoch for
DateTime. That leaves a hole over the first hours of 1970. Formatting
into the session timezone was the other candidate and needs the client to
guess server state, which goes silently wrong when the guess is stale.

HTTP keeps the old behaviour for now. There the combination is correct
for everyone on a UTC server, which is the default, so throwing would
break working code to protect the non-UTC case. Filed as N11d-http.

Co-Authored-By: Claude <noreply@anthropic.com>
All six comments held up against current HEAD. None were outdated.

Four defects:

The SQL scanner treated only '' as an in-string escape, so a literal
holding \' looked closed and the placeholder after it was skipped. The
type then fell back to inference. The same gap was in the HTTP scanner,
which this one was ported from, so both are fixed.

Variant alternatives were matched on the outer type name alone, so a
string[] took the first Array arm of Variant(Array(Int32), Array(String))
and every element went out unquoted. Matching now recurses into element,
key/value, and tuple element types, and checks tuple arity.

A Map read back from this client could not be bound again. The codec
surfaces a row as KeyValuePair[] so duplicate keys and order survive, and
only IDictionary was accepted.

A byte[] was decoded as UTF-8, so 0xFF became U+FFFD and the server
stored EF BF BD. Probing showed \xHH in the inner text yields the exact
byte on both transports, so bytes that are not valid UTF-8 now go out
that way. Valid UTF-8 keeps the readable form. Fixed on both transports.

Two tests that passed against the unfixed code:

The InsertAsync test called ExecuteAsync, so none of the overloads that
now forward parameters was covered. Both real overloads are, through a
parameterized target.

The one-shot sequence test named a placeholder, so inference never ran,
and an iterator method yields a fresh enumerator per enumeration so it
was not one-shot either. Coverage moved to BuildParameters, where SQL
that does not name the parameter reaches the inference pass, with a
sequence that is genuinely single-use.

Every new test was checked against the unfixed code and fails there. Two
that did not are gone: one asserted a difference the wire cannot show,
because a plain value formats the same whichever type wins.

Co-Authored-By: Claude <noreply@anthropic.com>
Coverage showed the Variant pair-sequence arm and AcceptsPairSequence had
no test at all, so the Map-shape matching added in the previous commit
was unexercised. Covered now, along with the paths that reject a
composite no alternative accepts.

Dropped a try/catch around NamedElementParser.Split that could never
fire: FormatException comes from ElementTypeStrings, which validates
arity, not from Split. The arity check that follows is the real one.

Parameters/ParameterTypeInference.cs 82% to 95%, MapPairs to 100%.

Co-Authored-By: Claude <noreply@anthropic.com>
Earlier commits fixed defects in the shipped HTTP client alongside the
TCP port: the byte[] arms in HttpParameterFormatter, backslash escapes in
the HTTP SQL scanner, their tests, and a CHANGELOG entry. That is a
behavioural change to a released transport and does not belong in a TCP
epic PR. CHANGELOG.md, RELEASENOTES.md and all four ClickHouse.Driver and
ClickHouse.Driver.Tests files are back at the base commit.

The TCP-side fixes stay. They are what makes the two formatters diverge,
so every divergence is now written up in N11a as a table: what HTTP does
today, and what fixing it would take. Six of the eight are HTTP defects,
two silently corrupt data. None is fixed here.

The comments that claimed a fix landed on both transports are corrected.

Co-Authored-By: Claude <noreply@anthropic.com>
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