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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,27 @@ public class PageView

## Supported Types

`String`, `Bool`, `Int8`/`Int16`/`Int32`/`Int64`, `UInt8`/`UInt16`/`UInt32`/`UInt64`, `Float32`/`Float64`, `Decimal(P, S)`, `Date`/`Date32`, `DateTime`, `DateTime64(P, 'TZ')`, `FixedString(N)`, `UUID`
| Category | ClickHouse Types | CLR Types |
|---|---|---|
| **Integers** | `Int8`/`Int16`/`Int32`/`Int64`, `UInt8`/`UInt16`/`UInt32`/`UInt64` | `sbyte`, `short`, `int`, `long`, `byte`, `ushort`, `uint`, `ulong` |
| **Big integers** | `Int128`, `Int256`, `UInt128`, `UInt256` | `BigInteger` |
| **Floats** | `Float32`, `Float64`, `BFloat16` | `float`, `double` |
| **Decimals** | `Decimal(P,S)`, `Decimal32(S)`, `Decimal64(S)`, `Decimal128(S)`, `Decimal256(S)` | `decimal` or `ClickHouseDecimal` (use `ClickHouseDecimal` for Decimal128/256 to avoid .NET decimal overflow) |
| **Bool** | `Bool` | `bool` |
| **Strings** | `String`, `FixedString(N)` | `string` |
| **Enums** | `Enum8(...)`, `Enum16(...)` | `string` or C# `enum` |
| **Date/time** | `Date`, `Date32`, `DateTime`, `DateTime64(P, 'TZ')` | `DateOnly`, `DateTime` |
| **Time** | `Time`, `Time64(N)` | `TimeSpan` |
| **UUID** | `UUID` | `Guid` |
| **Network** | `IPv4`, `IPv6` | `IPAddress` |
| **Arrays** | `Array(T)` | `T[]` or `List<T>` |
| **Maps** | `Map(K, V)` | `Dictionary<K,V>` |
| **Tuples** | `Tuple(T1, ...)` | `Tuple<...>` or `ValueTuple<...>` |
| **Wrappers** | `Nullable(T)`, `LowCardinality(T)` | Unwrapped automatically |

## Current Status

This provider is in early development. It supports **read-only queries** — you can map entities to existing ClickHouse tables and query them with LINQ.
This provider is in early development. It supports **read-only queries** and **inserts** — you can map entities to existing ClickHouse tables, query them with LINQ, and write data via `SaveChanges`.

### LINQ Queries

Expand Down Expand Up @@ -124,7 +140,7 @@ This calls `InsertBinaryAsync` directly, bypassing EF Core's change tracker enti
- UPDATE / DELETE (ClickHouse mutations are async, not OLTP-compatible)
- Migrations
- JOINs, subqueries, set operations
- Advanced types: Array, Tuple, Nullable(T), LowCardinality, Nested, TimeSpan/TimeOnly
- Nested type, Variant, Dynamic, JSON, Geo types

## Building

Expand Down
418 changes: 408 additions & 10 deletions src/EFCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Collections;
using System.Data.Common;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

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

public RelationalTypeMapping ElementMapping { get; }

/// <summary>
/// Creates a mapping for T[] CLR types.
/// </summary>
public ClickHouseArrayTypeMapping(RelationalTypeMapping elementMapping)
: base(
new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(
elementMapping.ClrType.MakeArrayType(),
comparer: CreateArrayComparer(elementMapping.ClrType)),
$"Array({elementMapping.StoreType})",
dbType: System.Data.DbType.Object))
{
ElementMapping = elementMapping;
}

/// <summary>
/// Creates a mapping for List&lt;T&gt; CLR types with a ValueConverter to T[].
/// </summary>
public ClickHouseArrayTypeMapping(RelationalTypeMapping elementMapping, ValueConverter converter, ValueComparer comparer)
: base(
new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(
converter.ModelClrType,
converter: converter,
comparer: comparer),
$"Array({elementMapping.StoreType})",
dbType: System.Data.DbType.Object))
{
ElementMapping = elementMapping;
}

protected ClickHouseArrayTypeMapping(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping)
: base(parameters)
{
ElementMapping = elementMapping;
}

protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
=> new ClickHouseArrayTypeMapping(parameters, ElementMapping);

public override MethodInfo GetDataReaderMethod()
=> GetValueMethod;

public override Expression CustomizeDataReaderExpression(Expression expression)
{
// When there's a ValueConverter (e.g. List<T> ↔ T[]), the data reader must produce
// the provider type (T[]). EF Core applies the converter afterward.
var targetType = Converter?.ProviderClrType ?? ClrType;
return Expression.Convert(expression, targetType);
}

protected override string GenerateNonNullSqlLiteral(object value)
{
var sb = new StringBuilder("[");
var first = true;
foreach (var element in (IEnumerable)value)
{
if (!first) sb.Append(", ");
sb.Append(element is null ? "NULL" : ElementMapping.GenerateSqlLiteral(element));
first = false;
}
sb.Append(']');
return sb.ToString();
}

private static ValueComparer CreateArrayComparer(Type elementType)
{
var method = typeof(ClickHouseArrayTypeMapping)
.GetMethod(nameof(CreateTypedArrayComparer), BindingFlags.Static | BindingFlags.NonPublic)!
.MakeGenericMethod(elementType);
return (ValueComparer)method.Invoke(null, null)!;
}

internal static ValueComparer CreateListComparer(Type elementType)
{
var method = typeof(ClickHouseArrayTypeMapping)
.GetMethod(nameof(CreateTypedListComparer), BindingFlags.Static | BindingFlags.NonPublic)!
.MakeGenericMethod(elementType);
return (ValueComparer)method.Invoke(null, null)!;
}

private static ValueComparer<T[]?> CreateTypedArrayComparer<T>()
=> new(
(a, b) => StructuralComparisons.StructuralEqualityComparer.Equals(a, b),
o => o == null ? 0 : StructuralComparisons.StructuralEqualityComparer.GetHashCode(o),
source => source == null ? null : (T[])source.Clone());

private static ValueComparer<List<T>?> CreateTypedListComparer<T>()
=> new(
(a, b) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual(b)),
o => o == null ? 0 : o.Aggregate(0, (hash, el) => HashCode.Combine(hash, el)),
source => source == null ? null : new List<T>(source));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Data.Common;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using ClickHouse.Driver.Numerics;
using Microsoft.EntityFrameworkCore.Storage;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

/// <summary>
/// Maps ClickHouse Decimal types to <see cref="ClickHouseDecimal"/> for full-precision support.
/// Use this instead of <see cref="ClickHouseDecimalTypeMapping"/> when you need Decimal128/256
/// precision beyond .NET decimal's 28-29 digit limit.
/// </summary>
public class ClickHouseBigDecimalTypeMapping : RelationalTypeMapping
{
private const int DefaultPrecision = 38;
private const int DefaultScale = 18;

private static readonly MethodInfo GetValueMethod =
typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetValue), [typeof(int)])!;

private static readonly MethodInfo ConvertMethod =
typeof(ClickHouseBigDecimalTypeMapping).GetMethod(nameof(ConvertToClickHouseDecimal), BindingFlags.Static | BindingFlags.NonPublic)!;

public ClickHouseBigDecimalTypeMapping(int? precision = null, int? scale = null)
: base(
new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(typeof(ClickHouseDecimal)),
FormatStoreType(precision ?? DefaultPrecision, scale ?? DefaultScale),
StoreTypePostfix.PrecisionAndScale,
System.Data.DbType.Object,
precision: precision ?? DefaultPrecision,
scale: scale ?? DefaultScale))
{
}

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

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

public override MethodInfo GetDataReaderMethod()
=> GetValueMethod;

public override Expression CustomizeDataReaderExpression(Expression expression)
=> Expression.Call(ConvertMethod, expression);

protected override string GenerateNonNullSqlLiteral(object value)
=> value is ClickHouseDecimal chd
? chd.ToString(CultureInfo.InvariantCulture)
: Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);

// The driver may return either ClickHouseDecimal (UseBigDecimal=true) or decimal (default).
// Handle both cases.
private static ClickHouseDecimal ConvertToClickHouseDecimal(object value)
=> value switch
{
ClickHouseDecimal chd => chd,
decimal d => new ClickHouseDecimal(d),
_ => ClickHouseDecimal.Parse(
Convert.ToString(value, CultureInfo.InvariantCulture)!,
CultureInfo.InvariantCulture)
};

private static string FormatStoreType(int precision, int scale)
=> $"Decimal({precision},{scale})";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Data.Common;
using System.Globalization;
using System.Linq.Expressions;
using System.Numerics;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Storage;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

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

private static readonly MethodInfo ConvertMethod =
typeof(ClickHouseBigIntegerTypeMapping).GetMethod(nameof(ConvertToBigInteger), BindingFlags.Static | BindingFlags.NonPublic)!;

public ClickHouseBigIntegerTypeMapping(string storeType = "Int128")
: base(storeType, typeof(BigInteger), System.Data.DbType.Object)
{
}

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

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

public override MethodInfo GetDataReaderMethod()
=> GetValueMethod;

public override Expression CustomizeDataReaderExpression(Expression expression)
=> Expression.Call(ConvertMethod, expression);

protected override string GenerateNonNullSqlLiteral(object value)
=> value is BigInteger bi
? bi.ToString(CultureInfo.InvariantCulture)
: Convert.ToString(value, CultureInfo.InvariantCulture)!;

private static BigInteger ConvertToBigInteger(object value)
=> value switch
{
BigInteger bi => bi,
Int128 i128 => (BigInteger)i128,
UInt128 u128 => (BigInteger)u128,
long l => new BigInteger(l),
ulong ul => new BigInteger(ul),
int i => new BigInteger(i),
uint ui => new BigInteger(ui),
_ => BigInteger.Parse(value.ToString()!, CultureInfo.InvariantCulture)
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ protected override string GenerateNonNullSqlLiteral(object value)
=> Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);

private static string FormatStoreType(int precision, int scale)
=> $"Decimal({precision}, {scale})";
=> $"Decimal({precision},{scale})";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

/// <summary>
/// Maps C# enum types to ClickHouse Enum8/Enum16/String columns via EnumToStringConverter.
/// The ClickHouse driver reads/writes enum values as strings, so the conversion is string-based.
/// </summary>
public class ClickHouseEnumTypeMapping : ClickHouseStringTypeMapping
{
public ClickHouseEnumTypeMapping(Type enumType)
: base(CreateParameters(enumType))
{
}

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

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

private static RelationalTypeMappingParameters CreateParameters(Type enumType)
{
var converterType = typeof(EnumToStringConverter<>).MakeGenericType(enumType);
var converter = (ValueConverter)Activator.CreateInstance(converterType)!;

return new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(enumType, converter: converter),
"String",
dbType: System.Data.DbType.String);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Data.Common;
using System.Linq.Expressions;
using System.Net;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Storage;

namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;

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

public ClickHouseIPAddressTypeMapping(string storeType = "IPv4")
: base(storeType, typeof(IPAddress), System.Data.DbType.Object)
{
}

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

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

public override MethodInfo GetDataReaderMethod()
=> GetValueMethod;

public override Expression CustomizeDataReaderExpression(Expression expression)
=> Expression.Convert(expression, typeof(IPAddress));

protected override string GenerateNonNullSqlLiteral(object value)
=> $"'{(IPAddress)value}'";
}
Loading
Loading