Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public class PageView
| **Tuples** | `Tuple(T1, ...)` | `Tuple<...>` or `ValueTuple<...>` |
| **Variant** | `Variant(T1, T2, ...)` | `object` |
| **Dynamic** | `Dynamic` | `object` |
| **JSON** | `Json` | `JsonNode` or `string` |
| **Wrappers** | `Nullable(T)`, `LowCardinality(T)` | Unwrapped automatically |

## Current Status
Expand Down Expand Up @@ -135,12 +136,63 @@ long rowsInserted = await ctx.BulkInsertAsync(events);

This calls `InsertBinaryAsync` directly, bypassing EF Core's change tracker entirely. Entities are **not** tracked after insert.

### JSON Columns

The provider supports ClickHouse's `Json` column type, mapping to `System.Text.Json.Nodes.JsonNode` or `string`.

```csharp
using System.Text.Json.Nodes;

public class Event
{
public long Id { get; set; }
public JsonNode? Payload { get; set; }
}

// In OnModelCreating:
entity.Property(e => e.Payload).HasColumnType("Json");
```

Reading and writing JSON works through both `SaveChanges` and `BulkInsertAsync`:

```csharp
ctx.Events.Add(new Event
{
Id = 1,
Payload = JsonNode.Parse("""{"action": "click", "x": 100, "y": 200}""")
});
await ctx.SaveChangesAsync();

var ev = await ctx.Events.Where(e => e.Id == 1).SingleAsync();
string action = ev.Payload!["action"]!.GetValue<string>(); // "click"
```

If you prefer working with raw JSON strings, map the property as `string` with a `Json` column type — the provider applies a `ValueConverter` automatically:

```csharp
public class Event
{
public long Id { get; set; }
public string? Payload { get; set; } // raw JSON string
}

entity.Property(e => e.Payload).HasColumnType("Json");
```

**Limitations:**

- **No JSON path translation** — `entity.Payload["name"]` in LINQ does not translate to ClickHouse's `data.name` SQL syntax. Filter on non-JSON columns or load entities and inspect JSON in memory.
- **No owned entity mapping** — `.ToJson()` / `StructuralJsonTypeMapping` is not supported. JSON columns are opaque `JsonNode` or `string` values.
- **`JsonElement` / `JsonDocument` not supported** — only `JsonNode` and `string` CLR types are mapped.
- **NULL semantics** — ClickHouse's JSON type returns `{}` (empty object) for NULL values rather than SQL NULL. A row inserted with `Data = null` will read back as an empty `JsonNode`, not `null`.
- **Integer precision** — ClickHouse JSON stores all integers as `Int64` unless the path is typed otherwise. When reading via `JsonNode`, use `GetValue<long>()` rather than `GetValue<int>()`.

### Not Yet Implemented

- UPDATE / DELETE (ClickHouse mutations are async, not OLTP-compatible)
- Migrations
- JOINs, subqueries, set operations
- Nested type, JSON, Geo types
- Geo types

## Building

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Data;
using System.Net;
using System.Numerics;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using ClickHouse.Driver.Numerics;
using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;
Expand Down Expand Up @@ -35,6 +36,7 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource
private static readonly RelationalTypeMapping UInt128Mapping = new ClickHouseBigIntegerTypeMapping("UInt128");
private static readonly RelationalTypeMapping UInt256Mapping = new ClickHouseBigIntegerTypeMapping("UInt256");
private static readonly RelationalTypeMapping TimeMapping = new ClickHouseTimeSpanTypeMapping();
private static readonly RelationalTypeMapping JsonMapping = new ClickHouseJsonTypeMapping();

private static readonly Dictionary<Type, RelationalTypeMapping> ClrTypeMappings = new()
{
Expand All @@ -58,6 +60,7 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource
{ typeof(BigInteger), Int128Mapping },
{ typeof(TimeSpan), TimeMapping },
{ typeof(ClickHouseDecimal), new ClickHouseBigDecimalTypeMapping() },
{ typeof(JsonNode), JsonMapping },
};

private static readonly Dictionary<string, RelationalTypeMapping> StoreTypeMappings =
Expand Down Expand Up @@ -98,6 +101,8 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource

["IPv4"] = IPv4Mapping,
["IPv6"] = IPv6Mapping,

["Json"] = JsonMapping,
};

// Matches a single-quoted string like 'UTC' or 'Asia/Tokyo'
Expand Down Expand Up @@ -203,11 +208,12 @@ public ClickHouseTypeMappingSource(
return baseName;
}

// Array(...), Map(...), Tuple(...), Variant(...) — return base name, inner parsing in FindMapping
// Array(...), Map(...), Tuple(...), Variant(...), Json(...) — return base name, inner parsing in FindMapping
if (string.Equals(baseName, "Array", StringComparison.OrdinalIgnoreCase)
|| string.Equals(baseName, "Map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(baseName, "Tuple", StringComparison.OrdinalIgnoreCase)
|| string.Equals(baseName, "Variant", StringComparison.OrdinalIgnoreCase))
|| string.Equals(baseName, "Variant", StringComparison.OrdinalIgnoreCase)
|| string.Equals(baseName, "Json", StringComparison.OrdinalIgnoreCase))
{
return baseName;
}
Expand All @@ -228,6 +234,7 @@ public ClickHouseTypeMappingSource(
?? FindTupleMapping(mappingInfo)
?? FindVariantMapping(mappingInfo)
?? FindDynamicMapping(mappingInfo)
?? FindJsonMapping(mappingInfo)
?? FindEnumMapping(mappingInfo)
?? FindExistingMapping(mappingInfo)
?? FindDecimalMapping(mappingInfo);
Expand Down Expand Up @@ -445,6 +452,18 @@ public ClickHouseTypeMappingSource(
return new ClickHouseDynamicTypeMapping(this);
}

private static RelationalTypeMapping? FindJsonMapping(in RelationalTypeMappingInfo mappingInfo)
{
if (!string.Equals(mappingInfo.StoreTypeNameBase, "Json", StringComparison.OrdinalIgnoreCase))
return null;

// string CLR type + Json store type → ValueConverter-backed mapping
if (mappingInfo.ClrType == typeof(string))
return new ClickHouseJsonTypeMapping(typeof(string));

return JsonMapping;
}

private static bool IsReferenceTuple(Type? type)
=> type is not null && type.IsGenericType && type.FullName?.StartsWith("System.Tuple`") == true;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Data.Common;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

public class ClickHouseJsonTypeMapping : RelationalTypeMapping
{
private static readonly MethodInfo GetValueMethod =
typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetValue), [typeof(int)])!;

private static readonly MethodInfo ToJsonStringMethod =
typeof(JsonNode).GetMethod(nameof(JsonNode.ToJsonString), [typeof(JsonSerializerOptions)])!;

public ClickHouseJsonTypeMapping()
: this(typeof(JsonNode))
{
}

public ClickHouseJsonTypeMapping(Type clrType)
: base(
new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(
clrType,
comparer: clrType == typeof(string)
? new ValueComparer<string?>(
(a, b) => a == b,
o => o == null ? 0 : o.GetHashCode(),
source => source)
: CreateJsonNodeComparer()),
"Json",
dbType: System.Data.DbType.Object))
{
}

protected ClickHouseJsonTypeMapping(RelationalTypeMappingParameters parameters)
: base(parameters)
{
}

protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
=> new ClickHouseJsonTypeMapping(parameters);

public override MethodInfo GetDataReaderMethod()
=> GetValueMethod;

public override Expression CustomizeDataReaderExpression(Expression expression)
{
// Driver returns JsonObject (subclass of JsonNode) from GetValue().
var asJsonNode = Expression.Convert(expression, typeof(JsonNode));

if (ClrType == typeof(string))
{
// Call JsonNode.ToJsonString(null) to produce a raw JSON string.
// No ValueConverter needed — the driver accepts string directly for writes.
return Expression.Call(
asJsonNode,
ToJsonStringMethod,
Expression.Constant(null, typeof(JsonSerializerOptions)));
}

return asJsonNode;
}

protected override string GenerateNonNullSqlLiteral(object value)
{
var json = value switch
{
JsonNode node => node.ToJsonString(),
string s => s,
_ => throw new InvalidOperationException(
$"Cannot generate SQL literal for JSON column from CLR type '{value.GetType().Name}'.")
};

return $"'{EscapeSqlLiteral(json)}'";
}

private static string EscapeSqlLiteral(string literal)
=> literal.Replace("\\", "\\\\").Replace("'", "\\'");

private static ValueComparer<JsonNode?> CreateJsonNodeComparer()
=> new(
(a, b) => (a == null && b == null) || (a != null && b != null && JsonNode.DeepEquals(a, b)),
o => o == null ? 0 : o.ToJsonString().GetHashCode(),
source => source == null ? null : JsonNode.Parse(source.ToJsonString()));
}
Loading
Loading