docs(AGENTS): capture recurring PR review feedback as conventions - #487
Conversation
Six rules that reviewers have had to repeat across recent PRs, so they are followed the first time instead of in a review round-trip: - Supported ClickHouse versions floor at 25.8 LTS (matches the CI matrix) - Don't tax a common path for a niche case; where benchmarks belong - Integration tests over mocks; don't restate TestCases.cs coverage - No redundant #if NET5/6_0_OR_GREATER guards; comments must be verified - Changelog and release-notes entries stay short
There was a problem hiding this comment.
Pull request overview
This PR updates AGENTS.md (the repository’s development guide) to codify several recurring review points as explicit conventions, aiming to reduce review round-trips by making expectations discoverable up-front.
Changes:
- Document the supported ClickHouse version floor (25.8 LTS+) and explicitly de-scope fixes/workarounds for older server behaviors.
- Add clearer performance guidance (avoid taxing hot paths for niche cases; prefer maintainer-run
/benchmark-compareover committing benchmark additions). - Expand testing/style guidance (integration-test rationale, avoid duplicating existing type coverage, avoid redundant
NET6_0_OR_GREATER-style guards, and avoid over-claiming in comments/changelog entries).
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Pushed The two review comments behind it (#480 "I don't think the new test cases cover anything that pre-existing tests don't", #482 "check for duplicates, probably in TestCases.cs") were both really about control cases: tests that pin behavior the change never touched, added as a safety net. Those are exactly what Self-audit while writing it: my own open #484 has one — |
Performance-related changes should be measured with BenchmarkDotNet. The rule is about what ships, not whether to benchmark: an ad-hoc benchmark written to answer one question can stay out of the PR, but one worth re-running later belongs in the repo.
|
Pushed I'd written "don't commit a new benchmark file", but that conflates two things. Performance-related changes should be measured with BenchmarkDotNet — the ask on #481/#482 was about what ships, not whether to benchmark. And #445 was the opposite ask ("can you do a quick benchmark?"), so a blanket prohibition would have contradicted it. Now: measure perf changes with BenchmarkDotNet and put the numbers in the PR description; an ad-hoc benchmark written to answer one question doesn't need to ship, but one worth re-running later belongs in the repo. |
Review feedback, and now also the house rules AGENTS.md picked up in #487: comments stay short and assert only what has been verified, and changelog entries stay to the user-visible change with the detail left to the PR. The two XML doc blocks in JsonType.cs kept only the non-obvious part -- that the text/bytes decision has to come from the ClickHouse type, since Array(UInt8) also reads as a byte[]. The changelog entry goes from 73 words to 52, identical in both files. No behaviour or test-assertion change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#485) * Fix JSON string values base64-corrupted under ReadStringsAsByteArrays `ReadStringsAsByteArrays` propagates into the JSON decoder, so every string leaf inside a `JSON` column was read as a `byte[]`. `ReadJsonValue`'s type switch had no arm for one, so it fell through to the `JsonSerializer.SerializeToElement` default, which renders a byte array as base64: `payload["event"].GetValue<string>()` returned `"aW5mbw=="` instead of `"info"`, with no exception and no visible change to the node's type. Numeric and boolean leaves were unaffected, which made the corruption easy to miss, and reading a document then writing it back persisted the base64 text into ClickHouse. A `Map(String, ...)` path failed differently: `ReadJsonMap` cast the decoded key straight to `string`, throwing `InvalidCastException`. Both now go through a shared `DecodeString` helper, which is what `ReadJsonFixedString` already did four lines away. Because `ReadJsonArray` and `ReadJsonMap` recurse through `ReadJsonNode` -> `ReadJsonValue`, the single new switch arm covers top-level, array-nested and map-nested strings at once, including `LowCardinality(String)` and `Nullable(String)`. Behavioural change, deliberately not honouring the flag inside JSON: RFC 8259 defines JSON strings as text, so the "a ClickHouse String is arbitrary bytes" rationale behind `ReadStringsAsByteArrays` does not apply within a JSON document, and `JsonValue` has no byte-array representation to expose instead. Decoding is lenient (invalid sequences yield U+FFFD rather than failing the row), matching `ReadJsonFixedString`. The flag is unchanged for ordinary `String`/`FixedString`/`Dynamic` columns. Adds `JsonStringAsByteArrayTests` (15 cases), covering the gap that allowed this: no existing test combined the flag with a `JSON` column. 12 of the 15 fail before this change; the other three are regression guards and the source documents which are which and why. They assert the decoded values, that string leaves are backed by `string` rather than `JsonElement` (the exact discriminator between the two code paths), that flag-on output is byte-identical to flag-off across ten JSON shapes covering both the hinted and the dynamic path-type construction sites, and that a read-then-write round trip no longer stores base64. No public API change; `PublicAPI/*.txt` is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Only decode a byte[] as text when it came from a string type Review caught a regression in the previous commit. The new `byte[]` arm keyed off the CLR type of the decoded value, but a `byte[]` is not on its own evidence of a string: `ArrayType.Read` allocates by `UnderlyingType.FrameworkType`, so `Array(UInt8)` materializes as a real `byte[]` too - with `ReadStringsAsByteArrays` on *or off*. `ReadJsonNode` intercepts `ArrayType` directly, so a plain `Array(UInt8)` hint was fine, but any wrapper it does not intercept passes the value through to `ReadJsonValue`, where the arm reinterpreted those bytes as UTF-8: JSON(v Variant(Array(UInt8), String)) [1, 2] "AQI=" -> "��" JSON(s SimpleAggregateFunction(anyLast, Array(UInt8))) "AQI=" -> "��" JSON(v Variant(Array(UInt8), String)) [255, 254] "//4=" -> two U+FFFD Two things made this worse than the bug being fixed. It fired at the *default* flag setting, so it was an undisclosed behaviour change for everyone rather than for opt-in users. And it was lossy where the old output was not: `"//4="` decodes back to `[255, 254]`, whereas the replacement characters have destroyed the bytes. The arm is now gated on `IsTextBacked(type)`, which decides from the originating ClickHouse type: `String`/`FixedString`, and those wrapped in `LowCardinality`, `Nullable` or `SimpleAggregateFunction`. `Variant` and `Dynamic` answer false deliberately - their subtype is chosen per value at read time and is not knowable from the static type, so they keep their existing behaviour instead of risking the same misinterpretation. Every shape the tests and release notes actually claim still decodes as text. The four new cases assert against fixed expected values rather than comparing flag-on to flag-off, because `Array(UInt8)` reads identically under both settings - the flag-on-equals-flag-off invariant is structurally incapable of catching this, and only pinning the literal output does. Verified: they fail with the guard removed and pass with it. Also corrects two claims the review found inaccurate: - the notes implied `Map` keys work under `LowCardinality`; `ReadJsonMap`'s guard is an exact `is not StringType` test, so `Map(LowCardinality(String), String)` still throws `NotSupportedException`. Pre-existing, now stated. - `Dynamic` was listed as unaffected, but a `Dynamic` column holding a `JSON` value does get the fix, since its leaves go through the JSON reader. Only a `Dynamic` holding a string still yields `byte[]`. And renames four `using var connection` locals that shadowed the inherited fixture field, since one test deliberately uses the inherited one to mean the flag-off connection. net9.0: 0 failed, 9584 passed, 142 skipped (baseline 9565/142, so exactly +19). Green on net6.0 and net10.0 too. Full build 0 errors, 293 warnings, matching baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Cover JsonReadMode.None and .String, and note which modes are affected The fix applies to whichever read mode decodes a JSON document structurally, and that is two of the three modes, not just the default. Tests only exercised `Binary`, so `None` was fixed without being covered and `String` was untouched without that being pinned. Verified against pristine main: JsonReadMode flag main this branch Binary true {"n":42,"s":"aW5mbw=="} {"n":42,"s":"info"} Binary false {"n":42,"s":"info"} unchanged None true {"n":42,"s":"aW5mbw=="} {"n":42,"s":"info"} None false {"n":42,"s":"info"} unchanged String true {"n":42,"s":"info"} unchanged String false {"n":42,"s":"info"} unchanged `None` differs from `Binary` only in not sending the server-side format setting (for read-only connections that cannot set one), so it decodes through the same path and carried the same bug. Its new case fails without the fix, alongside `Binary`'s. `String` never had the bug: the server sends the whole document as a single string and `JsonType.Read` returns it via `ExtendedBinaryReader.ReadString()` before any per-path type dispatch, so `ReadStringsAsByteArrays` cannot reach it. Pinned with a guard test rather than left implicit. That last point is worth more than a footnote, so the release notes now say it: one of the driver's three JSON read modes *already* returned real text regardless of the flag. Treating JSON strings as text is therefore not a new convention invented by this PR - `Binary` and `None` were the inconsistent ones. The lenient decoding lines up too, since `ExtendedBinaryReader` is constructed with a replacement-fallback UTF-8 decoder, which is exactly what `Encoding.UTF8.GetString` does. net9.0: 0 failed, 9587 passed, 142 skipped (baseline 9565/142, so exactly +22). Green on net6.0 and net10.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Condense the changelog entry and match it across both files The entry had grown to 273 words in CHANGELOG.md and longer still in RELEASENOTES.md, where it had also diverged into a differently-worded lead bullet with four sub-bullets. Both are wrong for this repo: - Every other Bug Fixes entry in `Unreleased` is byte-identical between the two files. Mine was the only one that differed. - Existing entries run 15-102 words (median ~40). At 273 it was nearly 3x the longest one in the file. Now one 73-word entry, identical in both files, covering just what a reader needs: the symptom, that a round trip persisted it, the map-key exception, the behavioural change with its precedent, and the action to take. The details that were in the sub-bullets - affected read modes, the `Array(UInt8)` exclusion, `Variant`/`Dynamic` scope, U+FFFD on invalid UTF-8 - stay in the PR description and the code comments, which is where that depth belongs. Notably most of them described things that did *not* change, which do not warrant changelog space. Docs only; no code or test changes. Verified: the `Unreleased` Bug Fixes blocks of the two files now diff clean, build 0 errors / 293 warnings, fixture 22/22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Trim the code comments and the changelog entry Review feedback, and now also the house rules AGENTS.md picked up in #487: comments stay short and assert only what has been verified, and changelog entries stay to the user-visible change with the detail left to the PR. The two XML doc blocks in JsonType.cs kept only the non-obvious part -- that the text/bytes decision has to come from the ClickHouse type, since Array(UInt8) also reads as a byte[]. The changelog entry goes from 73 words to 52, identical in both files. No behaviour or test-assertion change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
I reviewed the review comments on my last 10 PRs in this repo (#467, #469, #473, #474, #477, #479, #480, #481, #482, #484, plus #445/#446) and pulled out the feedback that recurred. Each rule below was asked for at least twice, or once in a way that killed a PR. Documenting them so they're followed the first time instead of costing a review round-trip.
What was repeated, and what I added
#if NET6_0_OR_GREATER, we already only target .net 6+"Two of these narrow existing guidance rather than adding new rules: the integration-test bullet already existed but didn't say why a mock passes when the server disagrees, and the framework list already said net6+ but not that guards below it are dead.
Notes
.github/workflows/tests.yml, so it stays checkable rather than becoming stale prose.Utilities/TestCases.cs/GetDataTypeSamples()verified as the shared source feeding the select, parameter, bulk-copy, serialisation, Dapper and DataAdapter suites — that's why it's the first place to check before adding a type test./benchmark-compareverified as a maintainer-gated PR-comment trigger (stresshouse-benchmark-compare.ymlrequiresOWNER/MEMBER/COLLABORATOR), so the wording says to ask a maintainer for it.