ClickHouse / ClickHouse/clickhouse-cs

Types: named Tuple/Map element of type JSON is unreadable — "Unknown type: x JSON"

Open
#543 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
C#
Stars
94
Forks
22
Avg merge
11h 26m
Merged PRs (30d)
22

Description

### Describe the bug

A column whose type contains a **named Tuple element of type `JSON`** cannot be read. Any
`SELECT` of such a column throws `ArgumentException: Unknown type: x JSON` before the first
row is returned.

The server always reports this element type with the spelling `JSON`. `JSON` is an *alias*
in the driver's type registry (`Aliases["JSON"] = "Json"`), and `TypeConverter.ExtractTypeName`
resolves the alias **before** it strips the element name — so the alias lookup is attempted on
`"x JSON"`, misses, and the name that survives (`"JSON"`) is never mapped to the registered
type name (`"Json"`).

There is **no server-side workaround**: declaring the column as `Tuple(x Json(...))`
(canonical spelling) is normalized by the server back to `Tuple(x JSON(...))`, so it fails
identically.

`JSON` appears to be the only alias reachable this way. Every other entry in `Aliases` is
normalized to its canonical name by the server before it reaches the client
(`Tuple(x INT)` -> `Tuple(x Int32)`, `Tuple(x TIMESTAMP)` -> `Tuple(x DateTime)`, etc.), so
those spellings never appear in a server-declared type.

### Steps to reproduce

1. Create a table with a named Tuple element of type `JSON`.
2. Insert one row.
3. `SELECT` the column with `ClickHouseClient.ExecuteReaderAsync`.

### Expected behaviour

The column is read successfully, the same way an unnamed `JSON` column
(`JSON(a Int64)`) and a named element of a non-aliased type (`Tuple(x Int32)`) already are.

The server considers the type valid and reports it as such:

```
$ curl -s --data-binary "DESCRIBE TABLE probe_tup" http://localhost:8123/
c Tuple(\n x JSON(a Int64))
```

### Code example

```csharp
using var client = new ClickHouseClient("Host=localhost;Port=8123");
await client.ExecuteNonQueryAsync("DROP TABLE IF EXISTS probe_tup");
await client.ExecuteNonQueryAsync(
"CREATE TABLE probe_tup (c Tuple(x JSON(a Int64))) ENGINE=Memory " +
"SETTINGS allow_experimental_json_type=1");
await client.ExecuteNonQueryAsync("INSERT INTO probe_tup VALUES (tuple('{\"a\":1}'))");

using var reader = await client.ExecuteReaderAsync("SELECT c FROM probe_tup");
while (await reader.ReadAsync())
Console.WriteLine(reader.GetValue(0)); // throws
```

### Error log

```
System.ArgumentException : Unknown type: x JSON(a Int64)
at ClickHouse.Driver.Types.TypeConverter.ParseClickHouseType(SyntaxTreeNode, TypeSettings)
TypeConverter.cs:302
at ClickHouse.Driver.Types.TupleType.Parse(...) TupleType.cs:116
```

Observed matrix (each row = create table, insert one row, `SELECT` through
`ExecuteReaderAsync`; server version 26.7.3.19):

| Declared type | Server-reported type | Result |
| --- | --- | --- |
| `Tuple(x JSON(a Int64))` | `Tuple(x JSON(a Int64))` | ❌ `Unknown type: x JSON(a Int64)` |
| `Tuple(x Json(a Int64))` | `Tuple(x JSON(a Int64))` | ❌ `Unknown type: x JSON(a Int64)` |
| `Tuple(x JSON)` | `Tuple(x JSON)` | ❌ `Unknown type: x JSON` |
| `Array(Tuple(x JSON))` | `Array(Tuple(x JSON))` | ❌ `Unknown type: x JSON` |
| `Map(String, Tuple(x JSON))` | `Map(String, Tuple(x JSON))` | ❌ `Unknown type: x JSON` |
| `JSON(a Int64)` (unnamed) | `JSON(a Int64)` | ✅ |
| `Nested(x JSON)` | `Nested(x JSON)` | ✅ |
| `Tuple(x Int32)` | `Tuple(x Int32)` | ✅ |
| `Tuple(x INT)` | `Tuple(x Int32)` | ✅ (server normalizes) |
| `Tuple(x DECIMAL(10, 2))` | `Tuple(x Decimal(10, 2))` | ✅ (server normalizes) |
| `Tuple(x TIMESTAMP)` | `Tuple(x DateTime)` | ✅ (server normalizes) |
| `Tuple(x BOOL)` | `Tuple(x Bool)` | ✅ (server normalizes) |
| `Tuple(x TEXT)` | `Tuple(x String)` | ✅ (server normalizes) |

### Root cause

`ClickHouse.Driver/Types/TypeConverter.cs:246-270` — `ExtractTypeName` resolves the alias
before stripping the element name:

```csharp
var typeName = node.Value.Trim().Trim('\''); // "x JSON(a Int64)" -> "x JSON"

if (Aliases.TryGetValue(typeName.ToUpperInvariant(), out var alias))
typeName = alias; // "X JSON" -> no match

if (typeName.Contains(' '))
{
var separator = typeName.IndexOfNameTypeSeparator();
if (separator > 0)
typeName = typeName.Substring(separator + 1).Trim(); // -> "JSON"
...
}

return typeName; // "JSON", never alias-resolved
```

`Aliases["JSON"] = "Json"` (TypeConverter.cs:96), but `SimpleTypes` and
`ParameterizedTypes` are keyed by the registered `Name` (`"Json"`), so the returned
`"JSON"` misses both lookups and `ParseClickHouseType` falls through to
`throw new ArgumentException("Unknown type: " + ...)` at TypeConverter.cs:302.

This is a different mechanism from #504 / PR #504, which fixed *where* the name/type
separator is located (`IndexOfNameTypeSeparator`). Here the separator is found correctly;
the defect is the ordering of alias resolution relative to name stripping.

### Suggested fix

Strip the element name **first**, then resolve the alias — a single resolution point:

```csharp
var typeName = node.Value.Trim().Trim('\'');

if (typeName.Contains(' ') && !Aliases.ContainsKey(typeName.ToUpperInvariant()))
{
// strip the element name
}

if (Aliases.TryGetValue(typeName.ToUpperInvariant(), out var alias))
typeName = alias;
```

Note the contrast case that must keep working: several aliases **contain a space**
(`BIGINT UNSIGNED`, `DOUBLE PRECISION`, `NATIONAL CHARACTER VARYING`, …). A naive
"strip before the first space" reordering would break those, so the alias table has to be
consulted for the whole string before deciding the string is a named element — or the
alias resolved on both the full string and the stripped remainder.

Suggested regression coverage (integration tests preferred per AGENTS.md): read a
`Tuple(x JSON(a Int64))` column, plus `Array(Tuple(x JSON))` and
`Map(String, Tuple(x JSON))`, and keep an assertion that a space-containing alias such as
`DOUBLE PRECISION` still resolves.

### Configuration

#### Environment
* Client version: `main` @ dd51bfe
* .NET version: 10.0
* OS: Ubuntu 24.04 (linux-x64)

#### ClickHouse server
* ClickHouse Server version: 26.7.3.19
* ClickHouse Server non-default settings: `allow_experimental_json_type=1`
* `CREATE TABLE` statements:
```sql
CREATE TABLE probe_tup (c Tuple(x JSON(a Int64))) ENGINE=Memory
SETTINGS allow_experimental_json_type=1;
```
* Sample data: `INSERT INTO probe_tup VALUES (tuple('{"a":1}'));`

Contributor guide

Open the contributing guide

Research direction

Start with ClickHouse.Driver/Types/TypeConverter.cs, especially ExtractTypeName and the alias table, then read AGENTS.md for the integration-test conventions. Reproduce the named JSON tuple failure and add coverage for Tuple, Array(Tuple), and Map(String, Tuple), while retaining a space-containing alias case; done means these types read successfully without regressing existing aliases.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
databases
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.