diff --git a/README.md b/README.md index c790ae0..e6a8a7a 100644 --- a/README.md +++ b/README.md @@ -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` | +| **Maps** | `Map(K, V)` | `Dictionary` | +| **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 @@ -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 diff --git a/src/EFCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs b/src/EFCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs index 954a17f..9769b5f 100644 --- a/src/EFCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs +++ b/src/EFCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs @@ -1,7 +1,12 @@ +using System.Collections.Concurrent; using System.Data; +using System.Net; +using System.Numerics; using System.Text.RegularExpressions; +using ClickHouse.Driver.Numerics; using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ClickHouse.EntityFrameworkCore.Storage.Internal; @@ -23,6 +28,13 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource private static readonly RelationalTypeMapping DateTime64Mapping = new ClickHouseDateTime64TypeMapping(); private static readonly RelationalTypeMapping DateOnlyMapping = new ClickHouseDateOnlyTypeMapping(); private static readonly RelationalTypeMapping GuidMapping = new ClickHouseGuidTypeMapping(); + private static readonly RelationalTypeMapping IPv4Mapping = new ClickHouseIPAddressTypeMapping("IPv4"); + private static readonly RelationalTypeMapping IPv6Mapping = new ClickHouseIPAddressTypeMapping("IPv6"); + private static readonly RelationalTypeMapping Int128Mapping = new ClickHouseBigIntegerTypeMapping("Int128"); + private static readonly RelationalTypeMapping Int256Mapping = new ClickHouseBigIntegerTypeMapping("Int256"); + 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 Dictionary ClrTypeMappings = new() { @@ -42,6 +54,10 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource { typeof(DateOnly), DateOnlyMapping }, { typeof(Guid), GuidMapping }, { typeof(char), StringMapping }, + { typeof(IPAddress), IPv4Mapping }, + { typeof(BigInteger), Int128Mapping }, + { typeof(TimeSpan), TimeMapping }, + { typeof(ClickHouseDecimal), new ClickHouseBigDecimalTypeMapping() }, }; private static readonly Dictionary StoreTypeMappings = @@ -58,8 +74,14 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource ["UInt32"] = UInt32Mapping, ["UInt64"] = UInt64Mapping, + ["Int128"] = Int128Mapping, + ["Int256"] = Int256Mapping, + ["UInt128"] = UInt128Mapping, + ["UInt256"] = UInt256Mapping, + ["Float32"] = Float32Mapping, ["Float64"] = Float64Mapping, + ["BFloat16"] = Float32Mapping, ["Bool"] = BoolMapping, ["UUID"] = GuidMapping, @@ -68,6 +90,14 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource ["Date32"] = DateOnlyMapping, ["DateTime"] = DateTimeMapping, ["DateTime64"] = DateTime64Mapping, + ["Time"] = TimeMapping, + + ["Enum8"] = StringMapping, + ["Enum16"] = StringMapping, + ["Enum"] = StringMapping, + + ["IPv4"] = IPv4Mapping, + ["IPv6"] = IPv6Mapping, }; // Matches a single-quoted string like 'UTC' or 'Asia/Tokyo' @@ -90,12 +120,15 @@ public ClickHouseTypeMappingSource( if (string.IsNullOrWhiteSpace(storeTypeName)) return null; + // Unwrap Nullable(...) and LowCardinality(...) wrappers + storeTypeName = UnwrapStoreType(storeTypeName); + var openParen = storeTypeName.IndexOf('('); if (openParen < 0) return storeTypeName.Trim(); var baseName = storeTypeName[..openParen].Trim(); - var closeParen = storeTypeName.LastIndexOf(')'); + var closeParen = FindMatchingCloseParen(storeTypeName, openParen); if (closeParen <= openParen) return baseName; @@ -114,7 +147,6 @@ public ClickHouseTypeMappingSource( var parts = args.Split(',', 2); if (int.TryParse(parts[0].Trim(), out var p)) precision = p; - // Timezone is extracted later from the full StoreTypeName return baseName; } @@ -124,18 +156,76 @@ public ClickHouseTypeMappingSource( return baseName; } + // Enum8('a'=1,'b'=2) or Enum16(...) — just return the base name + if (string.Equals(baseName, "Enum8", StringComparison.OrdinalIgnoreCase) + || string.Equals(baseName, "Enum16", StringComparison.OrdinalIgnoreCase) + || string.Equals(baseName, "Enum", StringComparison.OrdinalIgnoreCase)) + return baseName; + + // Decimal32(S), Decimal64(S), Decimal128(S), Decimal256(S) + // These take a single scale argument; precision is fixed per type. + if (string.Equals(baseName, "Decimal32", StringComparison.OrdinalIgnoreCase)) + { + precision = 9; + if (int.TryParse(args, out var s)) scale = s; + return baseName; + } + + if (string.Equals(baseName, "Decimal64", StringComparison.OrdinalIgnoreCase)) + { + precision = 18; + if (int.TryParse(args, out var s)) scale = s; + return baseName; + } + + // Note: Decimal128 (38 digits) and Decimal256 (76 digits) exceed .NET decimal's + // 28-29 digit precision. Values exceeding .NET's range will throw OverflowException + // at materialization time. This is a known limitation documented in AGENTS.md. + if (string.Equals(baseName, "Decimal128", StringComparison.OrdinalIgnoreCase)) + { + precision = 38; + if (int.TryParse(args, out var s)) scale = s; + return baseName; + } + + if (string.Equals(baseName, "Decimal256", StringComparison.OrdinalIgnoreCase)) + { + precision = 76; + if (int.TryParse(args, out var s)) scale = s; + return baseName; + } + + // Time64(N) + if (string.Equals(baseName, "Time64", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(args, out var p)) + precision = p; + return baseName; + } + + // Array(...), Map(...), Tuple(...) — 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)) + { + return baseName; + } + // For everything else (Decimal, etc.), let the base handle it return base.ParseStoreTypeName(storeTypeName, ref unicode, ref size, ref precision, ref scale); } protected override RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) // Call base first so plugin/extension type mappings can intercept before our defaults. - // This follows the Npgsql pattern and ensures custom ITypeMappingSourcePlugin - // implementations registered in DI are respected. => base.FindMapping(in mappingInfo) ?? FindDateTime64Mapping(mappingInfo) ?? FindDateTimeMapping(mappingInfo) ?? FindFixedStringMapping(mappingInfo) + ?? FindTimeMappings(mappingInfo) + ?? FindArrayMapping(mappingInfo) + ?? FindMapMapping(mappingInfo) + ?? FindTupleMapping(mappingInfo) + ?? FindEnumMapping(mappingInfo) ?? FindExistingMapping(mappingInfo) ?? FindDecimalMapping(mappingInfo); @@ -179,6 +269,173 @@ public ClickHouseTypeMappingSource( return new ClickHouseFixedStringTypeMapping(size.Value); } + private static RelationalTypeMapping? FindTimeMappings(in RelationalTypeMappingInfo mappingInfo) + { + if (string.Equals(mappingInfo.StoreTypeNameBase, "Time64", StringComparison.OrdinalIgnoreCase)) + return new ClickHouseTimeSpanTypeMapping(mappingInfo.Precision); + + return null; + } + + private RelationalTypeMapping? FindArrayMapping(in RelationalTypeMappingInfo mappingInfo) + { + RelationalTypeMapping? elementMapping = null; + + // Resolve element mapping from store type: Array(X) + if (string.Equals(mappingInfo.StoreTypeNameBase, "Array", StringComparison.OrdinalIgnoreCase)) + { + var storeTypeName = mappingInfo.StoreTypeName; + if (storeTypeName is null) + return null; + + var innerType = ExtractInnerType(storeTypeName, "Array"); + if (innerType is null) + return null; + + elementMapping = FindMapping(innerType); + } + + var clrType = mappingInfo.ClrType; + + // Resolve element mapping from CLR type if not already resolved from store type + if (elementMapping is null) + { + if (clrType is not null && clrType.IsArray && clrType.GetArrayRank() == 1) + elementMapping = FindMapping(clrType.GetElementType()!); + else if (clrType is not null && clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(List<>)) + elementMapping = FindMapping(clrType.GetGenericArguments()[0]); + } + + if (elementMapping is null) + return null; + + // If CLR type is List, use a ValueConverter to bridge List ↔ T[] + if (clrType is not null && clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(List<>)) + { + var listElementType = clrType.GetGenericArguments()[0]; + var converterType = typeof(ListToArrayConverter<>).MakeGenericType(listElementType); + var converter = (ValueConverter)Activator.CreateInstance(converterType)!; + var comparer = ClickHouseArrayTypeMapping.CreateListComparer(listElementType); + return new ClickHouseArrayTypeMapping(elementMapping, converter, comparer); + } + + return new ClickHouseArrayTypeMapping(elementMapping); + } + + private RelationalTypeMapping? FindMapMapping(in RelationalTypeMappingInfo mappingInfo) + { + // Resolve from store type: Map(K, V) + if (string.Equals(mappingInfo.StoreTypeNameBase, "Map", StringComparison.OrdinalIgnoreCase)) + { + var storeTypeName = mappingInfo.StoreTypeName; + if (storeTypeName is null) + return null; + + var innerTypes = ExtractInnerTypes(storeTypeName, "Map", 2); + if (innerTypes is null) + return null; + + var keyMapping = FindMapping(innerTypes[0]); + var valueMapping = FindMapping(innerTypes[1]); + if (keyMapping is null || valueMapping is null) + return null; + + return new ClickHouseMapTypeMapping(keyMapping, valueMapping); + } + + // Resolve from CLR type: Dictionary + var clrType = mappingInfo.ClrType; + if (clrType is not null && clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + var typeArgs = clrType.GetGenericArguments(); + var keyMapping = FindMapping(typeArgs[0]); + var valueMapping = FindMapping(typeArgs[1]); + if (keyMapping is not null && valueMapping is not null) + return new ClickHouseMapTypeMapping(keyMapping, valueMapping); + } + + return null; + } + + private RelationalTypeMapping? FindTupleMapping(in RelationalTypeMappingInfo mappingInfo) + { + // Resolve from store type: Tuple(T1, T2, ...) + if (string.Equals(mappingInfo.StoreTypeNameBase, "Tuple", StringComparison.OrdinalIgnoreCase)) + { + var storeTypeName = mappingInfo.StoreTypeName; + if (storeTypeName is null) + return null; + + var innerTypes = ExtractInnerTypes(storeTypeName, "Tuple"); + if (innerTypes is null || innerTypes.Count == 0) + return null; + + var elementMappings = new List(); + foreach (var innerType in innerTypes) + { + var mapping = FindMapping(innerType); + if (mapping is null) + return null; + elementMappings.Add(mapping); + } + + // If the CLR type is System.Tuple<>, use reference tuples (no conversion needed). + // Otherwise default to ValueTuple<> (requires conversion from driver's Tuple<>). + var useValueTuple = !IsReferenceTuple(mappingInfo.ClrType); + return new ClickHouseTupleTypeMapping(elementMappings, useValueTuple); + } + + // Resolve from CLR type: ValueTuple<...> or Tuple<...> + var clrType = mappingInfo.ClrType; + if (clrType is not null && clrType.IsGenericType) + { + var (isTuple, useVt) = ClassifyTupleType(clrType); + if (isTuple) + { + var typeArgs = clrType.GetGenericArguments(); + var elementMappings = new List(); + foreach (var arg in typeArgs) + { + var mapping = FindMapping(arg); + if (mapping is null) + return null; + elementMappings.Add(mapping); + } + + return new ClickHouseTupleTypeMapping(elementMappings, useVt); + } + } + + return null; + } + + private static bool IsReferenceTuple(Type? type) + => type is not null && type.IsGenericType && type.FullName?.StartsWith("System.Tuple`") == true; + + private static (bool IsTuple, bool UseValueTuple) ClassifyTupleType(Type type) + { + var fullName = type.GetGenericTypeDefinition().FullName; + if (fullName?.StartsWith("System.ValueTuple`") == true) + return (true, true); + if (fullName?.StartsWith("System.Tuple`") == true) + return (true, false); + return (false, false); + } + + // Cache enum mappings to avoid repeated reflection + converter creation + private static readonly ConcurrentDictionary EnumMappingCache = new(); + + private static RelationalTypeMapping? FindEnumMapping(in RelationalTypeMappingInfo mappingInfo) + { + var clrType = mappingInfo.ClrType; + if (clrType is null || !clrType.IsEnum) + return null; + + // ClickHouse Enum8/Enum16 values are read/written as strings by the driver. + // Use EnumToStringConverter to convert between C# enums and strings. + return EnumMappingCache.GetOrAdd(clrType, enumType => new ClickHouseEnumTypeMapping(enumType)); + } + private static RelationalTypeMapping? FindExistingMapping(in RelationalTypeMappingInfo mappingInfo) { if (!string.IsNullOrWhiteSpace(mappingInfo.StoreTypeNameBase) && @@ -204,13 +461,30 @@ public ClickHouseTypeMappingSource( private static RelationalTypeMapping? FindDecimalMapping(in RelationalTypeMappingInfo mappingInfo) { - if (mappingInfo.ClrType == typeof(decimal) || - string.Equals(mappingInfo.StoreTypeNameBase, "Decimal", StringComparison.OrdinalIgnoreCase)) - { - return new ClickHouseDecimalTypeMapping(mappingInfo.Precision, mappingInfo.Scale); - } + var baseName = mappingInfo.StoreTypeNameBase; + int? precision = mappingInfo.Precision; + int? scale = mappingInfo.Scale; + var useBigDecimal = mappingInfo.ClrType == typeof(ClickHouseDecimal); + + // Tier 1d: Decimal32/64/128/256 with fixed max precision + if (string.Equals(baseName, "Decimal32", StringComparison.OrdinalIgnoreCase)) + precision ??= 9; + else if (string.Equals(baseName, "Decimal64", StringComparison.OrdinalIgnoreCase)) + precision ??= 18; + else if (string.Equals(baseName, "Decimal128", StringComparison.OrdinalIgnoreCase)) + precision ??= 38; + else if (string.Equals(baseName, "Decimal256", StringComparison.OrdinalIgnoreCase)) + precision ??= 76; + else if (!useBigDecimal + && mappingInfo.ClrType != typeof(decimal) + && !string.Equals(baseName, "Decimal", StringComparison.OrdinalIgnoreCase)) + return null; - return null; + // ClickHouseDecimal supports the full range; .NET decimal is limited to 28-29 digits. + if (useBigDecimal) + return new ClickHouseBigDecimalTypeMapping(precision, scale); + + return new ClickHouseDecimalTypeMapping(precision, scale); } private static string? ExtractTimezone(string storeTypeName) @@ -218,4 +492,128 @@ public ClickHouseTypeMappingSource( var match = TimezoneRegex.Match(storeTypeName); return match.Success ? match.Groups[1].Value : null; } + + /// + /// Strips Nullable(...) and LowCardinality(...) wrappers from a store type name. + /// Handles nesting: LowCardinality(Nullable(String)) → String + /// + private static string UnwrapStoreType(string storeTypeName) + { + var s = storeTypeName.Trim(); + while (true) + { + if (TryUnwrapPrefix(s, "Nullable", out var inner) + || TryUnwrapPrefix(s, "LowCardinality", out inner)) + { + s = inner; + continue; + } + + break; + } + + return s; + } + + private static bool TryUnwrapPrefix(string s, string prefix, out string inner) + { + inner = s; + if (s.Length <= prefix.Length + 2 // need at least prefix + "(X)" + || !s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || s[prefix.Length] != '(') + return false; + + // Find matching close paren for the one at prefix.Length + var depth = 0; + for (var i = prefix.Length; i < s.Length; i++) + { + if (s[i] == '(') + depth++; + else if (s[i] == ')') + { + depth--; + if (depth == 0) + { + // Only unwrap if this closing paren is the last character + if (i == s.Length - 1) + { + inner = s[(prefix.Length + 1)..i].Trim(); + return true; + } + + return false; + } + } + } + + return false; + } + + /// + /// Extracts the single inner type from a parameterized store type like Array(Int32). + /// + private static string? ExtractInnerType(string storeTypeName, string prefix) + { + var types = ExtractInnerTypes(storeTypeName, prefix, 1); + return types?.Count == 1 ? types[0] : null; + } + + /// + /// Splits the inner types of a parameterized store type, respecting nested parens. + /// Example: Map(String, Array(Int32)) → ["String", "Array(Int32)"] + /// + private static List? ExtractInnerTypes(string storeTypeName, string prefix, int? expectedCount = null) + { + var openParen = prefix.Length; + if (storeTypeName.Length <= openParen + 2 + || storeTypeName[openParen] != '(') + return null; + + var closeParen = FindMatchingCloseParen(storeTypeName, openParen); + if (closeParen < 0) + return null; + + var inner = storeTypeName[(openParen + 1)..closeParen]; + var results = new List(); + var depth = 0; + var start = 0; + + for (var i = 0; i < inner.Length; i++) + { + if (inner[i] == '(') depth++; + else if (inner[i] == ')') depth--; + else if (inner[i] == ',' && depth == 0) + { + results.Add(inner[start..i].Trim()); + start = i + 1; + } + } + + results.Add(inner[start..].Trim()); + + if (expectedCount.HasValue && results.Count != expectedCount.Value) + return null; + + return results; + } + + /// + /// Finds the matching closing paren for the opening paren at the given index. + /// + private static int FindMatchingCloseParen(string s, int openParenIndex) + { + var depth = 0; + for (var i = openParenIndex; i < s.Length; i++) + { + if (s[i] == '(') depth++; + else if (s[i] == ')') + { + depth--; + if (depth == 0) + return i; + } + } + + return -1; + } } diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs new file mode 100644 index 0000000..1532f73 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs @@ -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; } + + /// + /// Creates a mapping for T[] CLR types. + /// + 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; + } + + /// + /// Creates a mapping for List<T> CLR types with a ValueConverter to T[]. + /// + 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[]), 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 CreateTypedArrayComparer() + => 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?> CreateTypedListComparer() + => 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(source)); +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigDecimalTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigDecimalTypeMapping.cs new file mode 100644 index 0000000..1010a5f --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigDecimalTypeMapping.cs @@ -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; + +/// +/// Maps ClickHouse Decimal types to for full-precision support. +/// Use this instead of when you need Decimal128/256 +/// precision beyond .NET decimal's 28-29 digit limit. +/// +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})"; +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigIntegerTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigIntegerTypeMapping.cs new file mode 100644 index 0000000..c839628 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseBigIntegerTypeMapping.cs @@ -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) + }; +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseDecimalTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseDecimalTypeMapping.cs index f14bf37..7597392 100644 --- a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseDecimalTypeMapping.cs +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseDecimalTypeMapping.cs @@ -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})"; } diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseEnumTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseEnumTypeMapping.cs new file mode 100644 index 0000000..6ddd8b7 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseEnumTypeMapping.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; + +/// +/// 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. +/// +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); + } +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseIPAddressTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseIPAddressTypeMapping.cs new file mode 100644 index 0000000..7ece9e1 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseIPAddressTypeMapping.cs @@ -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}'"; +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseMapTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseMapTypeMapping.cs new file mode 100644 index 0000000..980b3a3 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseMapTypeMapping.cs @@ -0,0 +1,82 @@ +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; + +namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; + +public class ClickHouseMapTypeMapping : RelationalTypeMapping +{ + private static readonly MethodInfo GetValueMethod = + typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetValue), [typeof(int)])!; + + public RelationalTypeMapping KeyMapping { get; } + public RelationalTypeMapping ValueMapping { get; } + + public ClickHouseMapTypeMapping(RelationalTypeMapping keyMapping, RelationalTypeMapping valueMapping) + : base( + new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + typeof(Dictionary<,>).MakeGenericType(keyMapping.ClrType, valueMapping.ClrType), + comparer: CreateDictionaryComparer(keyMapping.ClrType, valueMapping.ClrType)), + $"Map({keyMapping.StoreType}, {valueMapping.StoreType})", + dbType: System.Data.DbType.Object)) + { + KeyMapping = keyMapping; + ValueMapping = valueMapping; + } + + protected ClickHouseMapTypeMapping( + RelationalTypeMappingParameters parameters, + RelationalTypeMapping keyMapping, + RelationalTypeMapping valueMapping) + : base(parameters) + { + KeyMapping = keyMapping; + ValueMapping = valueMapping; + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new ClickHouseMapTypeMapping(parameters, KeyMapping, ValueMapping); + + public override MethodInfo GetDataReaderMethod() + => GetValueMethod; + + public override Expression CustomizeDataReaderExpression(Expression expression) + => Expression.Convert(expression, ClrType); + + protected override string GenerateNonNullSqlLiteral(object value) + { + var dict = (IDictionary)value; + var sb = new StringBuilder("map("); + var first = true; + foreach (DictionaryEntry entry in dict) + { + if (!first) sb.Append(", "); + sb.Append(KeyMapping.GenerateSqlLiteral(entry.Key)); + sb.Append(", "); + sb.Append(entry.Value is null ? "NULL" : ValueMapping.GenerateSqlLiteral(entry.Value)); + first = false; + } + sb.Append(')'); + return sb.ToString(); + } + + private static ValueComparer CreateDictionaryComparer(Type keyType, Type valueType) + { + var method = typeof(ClickHouseMapTypeMapping) + .GetMethod(nameof(CreateTypedDictionaryComparer), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(keyType, valueType); + return (ValueComparer)method.Invoke(null, null)!; + } + + private static ValueComparer?> CreateTypedDictionaryComparer() + where TKey : notnull + => new( + (a, b) => (a == null && b == null) || (a != null && b != null && a.Count == b.Count && !a.Except(b).Any()), + o => o == null ? 0 : o.Aggregate(0, (hash, kvp) => hash ^ HashCode.Combine(kvp.Key, kvp.Value)), + source => source == null ? null : new Dictionary(source)); +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTimeSpanTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTimeSpanTypeMapping.cs new file mode 100644 index 0000000..f62cd90 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTimeSpanTypeMapping.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; + +public class ClickHouseTimeSpanTypeMapping : RelationalTypeMapping +{ + private readonly int _fractionalDigits; + + public ClickHouseTimeSpanTypeMapping(int? precision = null) + : base( + FormatStoreType(precision), + typeof(TimeSpan), + System.Data.DbType.Time) + { + // Time = 0 fractional digits (seconds), Time64(N) = N fractional digits + _fractionalDigits = precision ?? 0; + } + + protected ClickHouseTimeSpanTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + _fractionalDigits = parameters.Precision ?? 0; + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new ClickHouseTimeSpanTypeMapping(parameters); + + protected override string GenerateNonNullSqlLiteral(object value) + { + var ts = (TimeSpan)value; + // Use absolute ticks to correctly handle negative TimeSpan values. + // ts.Duration() handles MinValue safely (returns MaxValue), unlike Math.Abs(Ticks). + var sign = ts < TimeSpan.Zero ? "-" : ""; + var abs = ts < TimeSpan.Zero ? ts.Duration() : ts; + var absTicks = abs.Ticks; + var totalSeconds = absTicks / TimeSpan.TicksPerSecond; + var hours = totalSeconds / 3600; + var minutes = (totalSeconds % 3600) / 60; + var seconds = totalSeconds % 60; + var basePart = $"{sign}{hours:00}:{minutes:00}:{seconds:00}"; + + if (_fractionalDigits <= 0) + return $"'{basePart}'"; + + // 1 tick = 100ns = 10^-7s, so 7 digits at full resolution. + // Pad with trailing zeros for precisions > 7 (Time64(8) / Time64(9)). + var digits = Math.Min(_fractionalDigits, 7); + var fraction = (absTicks % TimeSpan.TicksPerSecond).ToString("0000000")[..digits]; + if (_fractionalDigits > 7) + fraction = fraction.PadRight(_fractionalDigits, '0'); + return $"'{basePart}.{fraction}'"; + } + + private static string FormatStoreType(int? precision) + => precision.HasValue ? $"Time64({precision.Value})" : "Time"; +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTupleTypeMapping.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTupleTypeMapping.cs new file mode 100644 index 0000000..b91cd96 --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ClickHouseTupleTypeMapping.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Data.Common; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; + +public class ClickHouseTupleTypeMapping : RelationalTypeMapping +{ + private static readonly MethodInfo GetValueMethod = + typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetValue), [typeof(int)])!; + + private static readonly MethodInfo ConvertMethod = + typeof(ClickHouseTupleTypeMapping).GetMethod(nameof(ConvertToValueTuple), BindingFlags.Static | BindingFlags.NonPublic)!; + + // Cache compiled constructors per ValueTuple type to avoid Activator.CreateInstance per row + private static readonly ConcurrentDictionary ConstructorCache = new(); + + public IReadOnlyList ElementMappings { get; } + + public ClickHouseTupleTypeMapping(IReadOnlyList elementMappings, bool useValueTuple = true) + : base( + new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + MakeTupleType(elementMappings.Select(m => m.ClrType).ToArray(), useValueTuple)), + FormatStoreType(elementMappings), + dbType: System.Data.DbType.Object)) + { + ElementMappings = elementMappings; + } + + protected ClickHouseTupleTypeMapping( + RelationalTypeMappingParameters parameters, + IReadOnlyList elementMappings) + : base(parameters) + { + ElementMappings = elementMappings; + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new ClickHouseTupleTypeMapping(parameters, ElementMappings); + + public override MethodInfo GetDataReaderMethod() + => GetValueMethod; + + public override Expression CustomizeDataReaderExpression(Expression expression) + { + // The driver returns System.Tuple<>, but C# value tuples are ValueTuple<>. + // Use a conversion helper that handles both cases. + if (ClrType.IsValueType) + return Expression.Call(ConvertMethod.MakeGenericMethod(ClrType), expression); + + return Expression.Convert(expression, ClrType); + } + + // Converts the driver's Tuple<> to ValueTuple<> (or passes through if already correct type) + private static T ConvertToValueTuple(object value) where T : struct + { + if (value is T t) + return t; + + // Driver returns System.Tuple<>, need to create ValueTuple<> from its elements + if (value is ITuple tuple) + { + var args = new object?[tuple.Length]; + for (var i = 0; i < tuple.Length; i++) + args[i] = tuple[i]; + + var factory = ConstructorCache.GetOrAdd(typeof(T), static type => + { + var ctorParams = type.GetConstructors()[0].GetParameters(); + var argsParam = Expression.Parameter(typeof(object[]), "args"); + var bodyArgs = new Expression[ctorParams.Length]; + + for (var j = 0; j < ctorParams.Length; j++) + { + bodyArgs[j] = Expression.Convert( + Expression.ArrayIndex(argsParam, Expression.Constant(j)), + ctorParams[j].ParameterType); + } + + var body = Expression.New(type.GetConstructors()[0], bodyArgs); + return Expression.Lambda>(body, argsParam).Compile(); + }); + + return ((Func)factory)(args!); + } + + throw new InvalidCastException($"Cannot convert {value.GetType()} to {typeof(T)}"); + } + + protected override string GenerateNonNullSqlLiteral(object value) + { + var tuple = (ITuple)value; + var sb = new StringBuilder("("); + for (var i = 0; i < tuple.Length; i++) + { + if (i > 0) sb.Append(", "); + var element = tuple[i]; + sb.Append(element is null ? "NULL" : ElementMappings[i].GenerateSqlLiteral(element)); + } + sb.Append(')'); + return sb.ToString(); + } + + private static string FormatStoreType(IReadOnlyList elementMappings) + => $"Tuple({string.Join(", ", elementMappings.Select(m => m.StoreType))})"; + + private static Type MakeTupleType(Type[] elementTypes, bool useValueTuple) + { + if (elementTypes.Length is 0 or > 7) + throw new NotSupportedException($"Tuples with {elementTypes.Length} elements are not supported. Supported range: 1-7."); + + var genericDef = useValueTuple + ? elementTypes.Length switch + { + 1 => typeof(ValueTuple<>), + 2 => typeof(ValueTuple<,>), + 3 => typeof(ValueTuple<,,>), + 4 => typeof(ValueTuple<,,,>), + 5 => typeof(ValueTuple<,,,,>), + 6 => typeof(ValueTuple<,,,,,>), + 7 => typeof(ValueTuple<,,,,,,>), + _ => throw new NotSupportedException($"Tuples with {elementTypes.Length} elements are not supported.") + } + : elementTypes.Length switch + { + 1 => typeof(Tuple<>), + 2 => typeof(Tuple<,>), + 3 => typeof(Tuple<,,>), + 4 => typeof(Tuple<,,,>), + 5 => typeof(Tuple<,,,,>), + 6 => typeof(Tuple<,,,,,>), + 7 => typeof(Tuple<,,,,,,>), + _ => throw new NotSupportedException($"Tuples with {elementTypes.Length} elements are not supported.") + }; + + return genericDef.MakeGenericType(elementTypes); + } +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/Mapping/ListToArrayConverter.cs b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ListToArrayConverter.cs new file mode 100644 index 0000000..bdc532e --- /dev/null +++ b/src/EFCore.ClickHouse/Storage/Internal/Mapping/ListToArrayConverter.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; + +/// +/// Converts between List<T> (model) and T[] (provider/driver). +/// The ClickHouse driver always returns arrays; this converter lets users +/// map properties as List<T> for convenience. +/// +public class ListToArrayConverter : ValueConverter, T[]> +{ + public ListToArrayConverter() + : base( + list => list.ToArray(), + array => new List(array)) + { + } +} diff --git a/test/EFCore.ClickHouse.Tests/AllTypesQueryTests.cs b/test/EFCore.ClickHouse.Tests/AllTypesQueryTests.cs index 062b513..c614b5d 100644 --- a/test/EFCore.ClickHouse.Tests/AllTypesQueryTests.cs +++ b/test/EFCore.ClickHouse.Tests/AllTypesQueryTests.cs @@ -70,8 +70,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class AllTypesFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; // Known test data @@ -83,8 +81,7 @@ public class AllTypesFixture : IAsyncLifetime public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -126,10 +123,7 @@ INSERT INTO all_types VALUES await insertCmd.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class AllTypesQueryTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/DatabaseCreatorTests.cs b/test/EFCore.ClickHouse.Tests/DatabaseCreatorTests.cs index c494477..78d428c 100644 --- a/test/EFCore.ClickHouse.Tests/DatabaseCreatorTests.cs +++ b/test/EFCore.ClickHouse.Tests/DatabaseCreatorTests.cs @@ -8,21 +8,14 @@ namespace EFCore.ClickHouse.Tests; public class DatabaseCreatorFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = - new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } /// diff --git a/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs new file mode 100644 index 0000000..16a4da3 --- /dev/null +++ b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs @@ -0,0 +1,1177 @@ +using System.Net; +using System.Numerics; +using ClickHouse.Driver.Numerics; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.ClickHouse; +using Xunit; + +namespace EFCore.ClickHouse.Tests; + +#region Entities + +public class NullableLowCardinalityEntity +{ + public long Id { get; set; } + public int? NullableInt { get; set; } + public string? NullableString { get; set; } + public double? NullableDouble { get; set; } + public string LowCardString { get; set; } = string.Empty; + public string? LowCardNullableString { get; set; } + public decimal? NullableDecimal { get; set; } +} + +public class EnumEntity +{ + public long Id { get; set; } + public string Val8 { get; set; } = string.Empty; + public string Val16 { get; set; } = string.Empty; +} + +// C# enums matching the ClickHouse Enum8('a'=1,'b'=2,'c'=3) and Enum16('x'=100,'y'=200,'z'=300) +public enum TestEnum8 { a, b, c } +public enum TestEnum16 { x, y, z } + +public class ClrEnumEntity +{ + public long Id { get; set; } + public TestEnum8 Val8 { get; set; } + public TestEnum16 Val16 { get; set; } +} + +public class IpAddressEntity +{ + public long Id { get; set; } + public IPAddress ValIPv4 { get; set; } = IPAddress.Loopback; + public IPAddress ValIPv6 { get; set; } = IPAddress.IPv6Loopback; +} + +public class DecimalVariantEntity +{ + public long Id { get; set; } + public decimal ValDecimal32 { get; set; } + public decimal ValDecimal64 { get; set; } + public decimal ValDecimal128 { get; set; } +} + +public class BigDecimalEntity +{ + public long Id { get; set; } + public ClickHouseDecimal ValDecimal128 { get; set; } +} + +public class ArrayEntity +{ + public long Id { get; set; } + public int[] IntArray { get; set; } = []; + public string[] StringArray { get; set; } = []; +} + +public class ListArrayEntity +{ + public long Id { get; set; } + public List IntArray { get; set; } = []; + public List StringArray { get; set; } = []; +} + +public class MapEntity +{ + public long Id { get; set; } + public Dictionary StringIntMap { get; set; } = new(); +} + +public class TupleEntity +{ + public long Id { get; set; } + public (int, string) IntStringTuple { get; set; } +} + +public class RefTupleEntity +{ + public long Id { get; set; } + public Tuple IntStringTuple { get; set; } = Tuple.Create(0, ""); +} + +public class BigIntegerEntity +{ + public long Id { get; set; } + public BigInteger Val128 { get; set; } +} + +#endregion + +#region DbContexts + +public class NullableLowCardinalityDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public NullableLowCardinalityDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("nullable_lowcard_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.NullableInt).HasColumnName("nullable_int").HasColumnType("Nullable(Int32)"); + e.Property(x => x.NullableString).HasColumnName("nullable_string").HasColumnType("Nullable(String)"); + e.Property(x => x.NullableDouble).HasColumnName("nullable_double").HasColumnType("Nullable(Float64)"); + e.Property(x => x.LowCardString).HasColumnName("lowcard_string").HasColumnType("LowCardinality(String)"); + e.Property(x => x.LowCardNullableString).HasColumnName("lowcard_nullable_string").HasColumnType("LowCardinality(Nullable(String))"); + e.Property(x => x.NullableDecimal).HasColumnName("nullable_decimal").HasColumnType("Nullable(Decimal(18, 4))"); + }); + } +} + +public class EnumDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public EnumDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("enum_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Val8).HasColumnName("val8").HasColumnType("Enum8('a'=1,'b'=2,'c'=3)"); + e.Property(x => x.Val16).HasColumnName("val16").HasColumnType("Enum16('x'=100,'y'=200,'z'=300)"); + }); + } +} + +public class ClrEnumDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public ClrEnumDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("enum_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Val8).HasColumnName("val8").HasColumnType("Enum8('a'=1,'b'=2,'c'=3)"); + e.Property(x => x.Val16).HasColumnName("val16").HasColumnType("Enum16('x'=100,'y'=200,'z'=300)"); + }); + } +} + +public class IpAddressDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public IpAddressDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("ipaddr_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.ValIPv4).HasColumnName("val_ipv4").HasColumnType("IPv4"); + e.Property(x => x.ValIPv6).HasColumnName("val_ipv6").HasColumnType("IPv6"); + }); + } +} + +public class DecimalVariantDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public DecimalVariantDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("decimal_variant_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.ValDecimal32).HasColumnName("val_d32").HasColumnType("Decimal32(4)"); + e.Property(x => x.ValDecimal64).HasColumnName("val_d64").HasColumnType("Decimal64(8)"); + e.Property(x => x.ValDecimal128).HasColumnName("val_d128").HasColumnType("Decimal128(18)"); + }); + } +} + +public class BigDecimalDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public BigDecimalDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("decimal_variant_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.ValDecimal128).HasColumnName("val_d128").HasColumnType("Decimal128(18)"); + }); + } +} + +public class ArrayDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public ArrayDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("array_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.IntArray).HasColumnName("int_array").HasColumnType("Array(Int32)"); + e.Property(x => x.StringArray).HasColumnName("string_array").HasColumnType("Array(String)"); + }); + } +} + +public class ListArrayDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public ListArrayDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("array_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.IntArray).HasColumnName("int_array").HasColumnType("Array(Int32)"); + e.Property(x => x.StringArray).HasColumnName("string_array").HasColumnType("Array(String)"); + }); + } +} + +public class MapDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public MapDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("map_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.StringIntMap).HasColumnName("str_int_map").HasColumnType("Map(String, Int32)"); + }); + } +} + +public class TupleDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public TupleDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("tuple_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.IntStringTuple).HasColumnName("int_str_tuple").HasColumnType("Tuple(Int32, String)"); + }); + } +} + +public class RefTupleDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public RefTupleDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("tuple_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.IntStringTuple).HasColumnName("int_str_tuple").HasColumnType("Tuple(Int32, String)"); + }); + } +} + +public class BigIntegerDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public BigIntegerDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("bigint_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Val128).HasColumnName("val128").HasColumnType("Int128"); + }); + } +} + +#endregion + +#region Fixtures + +public class ExtendedTypesFixture : IAsyncLifetime +{ + public string ConnectionString { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + ConnectionString = await SharedContainer.GetConnectionStringAsync(); + + using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); + await connection.OpenAsync(); + + // Nullable / LowCardinality table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE nullable_lowcard_test ( + id Int64, + nullable_int Nullable(Int32), + nullable_string Nullable(String), + nullable_double Nullable(Float64), + lowcard_string LowCardinality(String), + lowcard_nullable_string LowCardinality(Nullable(String)), + nullable_decimal Nullable(Decimal(18, 4)) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO nullable_lowcard_test VALUES + (1, 42, 'hello', 3.14, 'low1', 'lcn1', 123.4567), + (2, NULL, NULL, NULL, 'low2', NULL, NULL), + (3, -100, 'world', -1.5, 'low1', 'lcn2', 0.0001) + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Enum table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE enum_test ( + id Int64, + val8 Enum8('a'=1,'b'=2,'c'=3), + val16 Enum16('x'=100,'y'=200,'z'=300) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO enum_test VALUES + (1, 'a', 'x'), + (2, 'b', 'y'), + (3, 'c', 'z') + """; + await cmd.ExecuteNonQueryAsync(); + } + + // IP address table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE ipaddr_test ( + id Int64, + val_ipv4 IPv4, + val_ipv6 IPv6 + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO ipaddr_test VALUES + (1, '127.0.0.1', '::1'), + (2, '192.168.1.1', 'fe80::1'), + (3, '10.0.0.1', '2001:db8::1') + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Decimal variant table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE decimal_variant_test ( + id Int64, + val_d32 Decimal32(4), + val_d64 Decimal64(8), + val_d128 Decimal128(18) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO decimal_variant_test VALUES + (1, 12345.6789, 12345678.12345678, 123456789.012345678901234567), + (2, -0.0001, -0.00000001, -0.000000000000000001), + (3, 0.0000, 0.00000000, 0.000000000000000000) + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Array table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE array_test ( + id Int64, + int_array Array(Int32), + string_array Array(String) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO array_test VALUES + (1, [1, 2, 3], ['a', 'b', 'c']), + (2, [], []), + (3, [42, -1, 0, 100], ['hello', 'world']) + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Map table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE map_test ( + id Int64, + str_int_map Map(String, Int32) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO map_test VALUES + (1, {'a': 1, 'b': 2}), + (2, {}), + (3, {'x': 42, 'y': -1, 'z': 0}) + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Tuple table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE tuple_test ( + id Int64, + int_str_tuple Tuple(Int32, String) + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO tuple_test VALUES + (1, (42, 'hello')), + (2, (0, '')), + (3, (-1, 'world')) + """; + await cmd.ExecuteNonQueryAsync(); + } + + // BigInteger table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE bigint_test ( + id Int64, + val128 Int128 + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO bigint_test VALUES + (1, 123456789012345678), + (2, -99999999999999999), + (3, 0) + """; + await cmd.ExecuteNonQueryAsync(); + } + } + + public Task DisposeAsync() => Task.CompletedTask; +} + +#endregion + +#region Collection Fixture + +[CollectionDefinition("ExtendedTypes")] +public class ExtendedTypesCollection : ICollectionFixture; + +#endregion + +#region Tests + +[Collection("ExtendedTypes")] +public class NullableLowCardinalityTests +{ + private readonly ExtendedTypesFixture _fixture; + public NullableLowCardinalityTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_NullableColumns_RoundTrip() + { + await using var ctx = new NullableLowCardinalityDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + // Row 1: non-null values + Assert.Equal(42, rows[0].NullableInt); + Assert.Equal("hello", rows[0].NullableString); + Assert.True(Math.Abs(rows[0].NullableDouble!.Value - 3.14) < 0.01); + Assert.Equal("low1", rows[0].LowCardString); + Assert.Equal("lcn1", rows[0].LowCardNullableString); + Assert.Equal(123.4567m, rows[0].NullableDecimal); + + // Row 2: null values + Assert.Null(rows[1].NullableInt); + Assert.Null(rows[1].NullableString); + Assert.Null(rows[1].NullableDouble); + Assert.Equal("low2", rows[1].LowCardString); + Assert.Null(rows[1].LowCardNullableString); + Assert.Null(rows[1].NullableDecimal); + } + + [Fact] + public async Task Where_NullableInt_HasValue() + { + await using var ctx = new NullableLowCardinalityDbContext(_fixture.ConnectionString); + var results = await ctx.Entities + .Where(e => e.NullableInt != null && e.NullableInt > 0) + .AsNoTracking().ToListAsync(); + + Assert.Single(results); + Assert.Equal(42, results[0].NullableInt); + } + + [Fact] + public async Task Where_NullableInt_IsNull() + { + await using var ctx = new NullableLowCardinalityDbContext(_fixture.ConnectionString); + var results = await ctx.Entities + .Where(e => e.NullableInt == null) + .AsNoTracking().ToListAsync(); + + Assert.Single(results); + Assert.Equal(2, results[0].Id); + } + + [Fact] + public async Task Where_LowCardinality_Filter() + { + await using var ctx = new NullableLowCardinalityDbContext(_fixture.ConnectionString); + var results = await ctx.Entities + .Where(e => e.LowCardString == "low1") + .OrderBy(e => e.Id) + .AsNoTracking().ToListAsync(); + + Assert.Equal(2, results.Count); + Assert.Equal(1, results[0].Id); + Assert.Equal(3, results[1].Id); + } +} + +[Collection("ExtendedTypes")] +public class EnumTests +{ + private readonly ExtendedTypesFixture _fixture; + public EnumTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_Enums_AsStrings() + { + await using var ctx = new EnumDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + Assert.Equal("a", rows[0].Val8); + Assert.Equal("x", rows[0].Val16); + Assert.Equal("b", rows[1].Val8); + Assert.Equal("y", rows[1].Val16); + Assert.Equal("c", rows[2].Val8); + Assert.Equal("z", rows[2].Val16); + } + + [Fact] + public async Task Where_Enum8_Filter() + { + await using var ctx = new EnumDbContext(_fixture.ConnectionString); + var result = await ctx.Entities + .Where(e => e.Val8 == "b") + .AsNoTracking().SingleOrDefaultAsync(); + + Assert.NotNull(result); + Assert.Equal(2, result.Id); + } +} + +[Collection("ExtendedTypes")] +public class ClrEnumTests +{ + private readonly ExtendedTypesFixture _fixture; + public ClrEnumTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_ClrEnums_RoundTrip() + { + await using var ctx = new ClrEnumDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + Assert.Equal(TestEnum8.a, rows[0].Val8); + Assert.Equal(TestEnum16.x, rows[0].Val16); + Assert.Equal(TestEnum8.b, rows[1].Val8); + Assert.Equal(TestEnum16.y, rows[1].Val16); + Assert.Equal(TestEnum8.c, rows[2].Val8); + Assert.Equal(TestEnum16.z, rows[2].Val16); + } + + [Fact] + public async Task Where_ClrEnum_Filter() + { + await using var ctx = new ClrEnumDbContext(_fixture.ConnectionString); + var result = await ctx.Entities + .Where(e => e.Val8 == TestEnum8.b) + .AsNoTracking().SingleOrDefaultAsync(); + + Assert.NotNull(result); + Assert.Equal(2, result.Id); + Assert.Equal(TestEnum16.y, result.Val16); + } +} + +[Collection("ExtendedTypes")] +public class IpAddressTests +{ + private readonly ExtendedTypesFixture _fixture; + public IpAddressTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_IpAddresses_RoundTrip() + { + await using var ctx = new IpAddressDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + Assert.Equal(IPAddress.Parse("127.0.0.1"), rows[0].ValIPv4); + Assert.Equal(IPAddress.Parse("::1"), rows[0].ValIPv6); + Assert.Equal(IPAddress.Parse("192.168.1.1"), rows[1].ValIPv4); + Assert.Equal(IPAddress.Parse("10.0.0.1"), rows[2].ValIPv4); + } + + [Fact] + public async Task Where_IPv4_Equality() + { + await using var ctx = new IpAddressDbContext(_fixture.ConnectionString); + var target = IPAddress.Parse("192.168.1.1"); + var result = await ctx.Entities + .Where(e => e.ValIPv4 == target) + .AsNoTracking().SingleOrDefaultAsync(); + + Assert.NotNull(result); + Assert.Equal(2, result.Id); + } +} + +[Collection("ExtendedTypes")] +public class DecimalVariantTests +{ + private readonly ExtendedTypesFixture _fixture; + public DecimalVariantTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_DecimalVariants_RoundTrip() + { + await using var ctx = new DecimalVariantDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal(12345.6789m, rows[0].ValDecimal32); + Assert.Equal(12345678.12345678m, rows[0].ValDecimal64); + + Assert.Equal(0.0000m, rows[2].ValDecimal32); + } + + [Fact] + public async Task Where_Decimal32_Comparison() + { + await using var ctx = new DecimalVariantDbContext(_fixture.ConnectionString); + var results = await ctx.Entities + .Where(e => e.ValDecimal32 > 0m) + .AsNoTracking().ToListAsync(); + + Assert.Single(results); + Assert.Equal(1, results[0].Id); + } +} + +[Collection("ExtendedTypes")] +public class BigDecimalTests +{ + private readonly ExtendedTypesFixture _fixture; + public BigDecimalTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_ClickHouseDecimal_RoundTrip() + { + await using var ctx = new BigDecimalDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + // Row 1: 123456789.012345678901234567 + Assert.True(rows[0].ValDecimal128 != default); + + // Row 3: 0 + Assert.Equal(new ClickHouseDecimal(0m), rows[2].ValDecimal128); + } +} + +[Collection("ExtendedTypes")] +public class ArrayTests +{ + private readonly ExtendedTypesFixture _fixture; + public ArrayTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_Arrays_RoundTrip() + { + await using var ctx = new ArrayDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal([1, 2, 3], rows[0].IntArray); + Assert.Equal(["a", "b", "c"], rows[0].StringArray); + + Assert.Empty(rows[1].IntArray); + Assert.Empty(rows[1].StringArray); + + Assert.Equal([42, -1, 0, 100], rows[2].IntArray); + Assert.Equal(["hello", "world"], rows[2].StringArray); + } +} + +[Collection("ExtendedTypes")] +public class ListArrayTests +{ + private readonly ExtendedTypesFixture _fixture; + public ListArrayTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_ListArrays_RoundTrip() + { + await using var ctx = new ListArrayDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal([1, 2, 3], rows[0].IntArray); + Assert.Equal(["a", "b", "c"], rows[0].StringArray); + + Assert.Empty(rows[1].IntArray); + Assert.Empty(rows[1].StringArray); + + Assert.Equal([42, -1, 0, 100], rows[2].IntArray); + Assert.Equal(["hello", "world"], rows[2].StringArray); + } +} + +[Collection("ExtendedTypes")] +public class MapTests +{ + private readonly ExtendedTypesFixture _fixture; + public MapTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_Maps_RoundTrip() + { + await using var ctx = new MapDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal(2, rows[0].StringIntMap.Count); + Assert.Equal(1, rows[0].StringIntMap["a"]); + Assert.Equal(2, rows[0].StringIntMap["b"]); + + Assert.Empty(rows[1].StringIntMap); + + Assert.Equal(3, rows[2].StringIntMap.Count); + Assert.Equal(42, rows[2].StringIntMap["x"]); + } +} + +[Collection("ExtendedTypes")] +public class TupleTests +{ + private readonly ExtendedTypesFixture _fixture; + public TupleTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_Tuples_RoundTrip() + { + await using var ctx = new TupleDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal((42, "hello"), rows[0].IntStringTuple); + Assert.Equal((0, ""), rows[1].IntStringTuple); + Assert.Equal((-1, "world"), rows[2].IntStringTuple); + } +} + +[Collection("ExtendedTypes")] +public class RefTupleTests +{ + private readonly ExtendedTypesFixture _fixture; + public RefTupleTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_RefTuples_RoundTrip() + { + await using var ctx = new RefTupleDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal(Tuple.Create(42, "hello"), rows[0].IntStringTuple); + Assert.Equal(Tuple.Create(0, ""), rows[1].IntStringTuple); + Assert.Equal(Tuple.Create(-1, "world"), rows[2].IntStringTuple); + } +} + +[Collection("ExtendedTypes")] +public class BigIntegerTests +{ + private readonly ExtendedTypesFixture _fixture; + public BigIntegerTests(ExtendedTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ReadAll_BigInteger_RoundTrip() + { + await using var ctx = new BigIntegerDbContext(_fixture.ConnectionString); + var rows = await ctx.Entities.OrderBy(e => e.Id).AsNoTracking().ToListAsync(); + + Assert.Equal(3, rows.Count); + + Assert.Equal(BigInteger.Parse("123456789012345678"), rows[0].Val128); + Assert.Equal(BigInteger.Parse("-99999999999999999"), rows[1].Val128); + Assert.Equal(BigInteger.Zero, rows[2].Val128); + } + + [Fact] + public async Task Where_BigInteger_Filter() + { + await using var ctx = new BigIntegerDbContext(_fixture.ConnectionString); + var result = await ctx.Entities + .Where(e => e.Id == 1) + .AsNoTracking().SingleAsync(); + + Assert.Equal(BigInteger.Parse("123456789012345678"), result.Val128); + } +} + +#endregion + +#region Unit Tests for Type Mapping Source + +public class TypeMappingSourceUnwrapTests +{ + /// + /// Verifies that ParseStoreTypeName correctly unwraps Nullable/LowCardinality + /// wrappers by checking that the type mapping source resolves them. + /// + [Theory] + [InlineData("Nullable(Int32)")] + [InlineData("Nullable(String)")] + [InlineData("LowCardinality(String)")] + [InlineData("LowCardinality(Nullable(String))")] + [InlineData("Nullable(Float64)")] + [InlineData("Nullable(Decimal(18, 4))")] + public void FindMapping_UnwrapsWrapperTypes(string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(object), storeType); + Assert.NotNull(mapping); + } + + [Theory] + [InlineData("Enum8")] + [InlineData("Enum16")] + [InlineData("BFloat16")] + [InlineData("IPv4")] + [InlineData("IPv6")] + [InlineData("Int128")] + [InlineData("UInt256")] + public void FindMapping_NewStoreTypes_Resolves(string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(object), storeType); + Assert.NotNull(mapping); + } + + [Theory] + [InlineData("Decimal32(4)")] + [InlineData("Decimal64(8)")] + [InlineData("Decimal128(18)")] + [InlineData("Decimal256(38)")] + public void FindMapping_DecimalVariants_Resolves(string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(decimal), storeType); + Assert.NotNull(mapping); + } + + [Theory] + [InlineData("Array(Int32)")] + [InlineData("Array(String)")] + [InlineData("Array(Nullable(Int32))")] + [InlineData("Array(LowCardinality(String))")] + [InlineData("Map(String, Int32)")] + [InlineData("Map(LowCardinality(String), Nullable(Int64))")] + [InlineData("Tuple(Int32, String)")] + [InlineData("Tuple(Nullable(Int32), String)")] + [InlineData("Tuple(Nullable(Int32), LowCardinality(String))")] + public void FindMapping_ContainerTypes_Resolves(string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(object), storeType); + Assert.NotNull(mapping); + } + + [Fact] + public void FindMapping_TimeSpan_ResolvesFromClrType() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TimeSpan)); + Assert.NotNull(mapping); + Assert.Equal("Time", mapping.StoreType); + } + + [Theory] + [InlineData("Time")] + [InlineData("Time64(3)")] + [InlineData("Time64(6)")] + [InlineData("Time64(9)")] + public void FindMapping_TimeStoreTypes_Resolves(string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TimeSpan), storeType); + Assert.NotNull(mapping); + Assert.Equal(typeof(TimeSpan), mapping.ClrType); + } + + [Fact] + public void TimeSpanMapping_GeneratesCorrectLiteral_WholeSeconds() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TimeSpan))!; + var literal = mapping.GenerateSqlLiteral(new TimeSpan(1, 30, 45)); + Assert.Equal("'01:30:45'", literal); + } + + [Fact] + public void TimeSpanMapping_GeneratesCorrectLiteral_SubSecond() + { + var source = GetTypeMappingSource(); + // Use Time64(3) to get millisecond precision + var mapping = source.FindMapping(typeof(TimeSpan), "Time64(3)")!; + var literal = mapping.GenerateSqlLiteral(TimeSpan.FromMilliseconds(1500)); + // 1.500 seconds with 3 fractional digits + Assert.Equal("'00:00:01.500'", literal); + } + + [Fact] + public void TimeSpanMapping_GeneratesCorrectLiteral_NegativeDuration() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TimeSpan))!; + var literal = mapping.GenerateSqlLiteral(TimeSpan.FromHours(-1.5)); + Assert.Equal("'-01:30:00'", literal); + } + + [Fact] + public void TimeSpanMapping_Time_TruncatesSubSecond() + { + var source = GetTypeMappingSource(); + // Default Time mapping has 0 fractional digits + var mapping = source.FindMapping(typeof(TimeSpan))!; + var literal = mapping.GenerateSqlLiteral(TimeSpan.FromMilliseconds(1500)); + // Time (seconds) should not include fractional part + Assert.Equal("'00:00:01'", literal); + } + + [Fact] + public void FindMapping_ClrEnum_ResolvesWithConverter() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TestEnum8)); + Assert.NotNull(mapping); + Assert.Equal(typeof(TestEnum8), mapping.ClrType); + Assert.Equal("String", mapping.StoreType); + // The converter should be an EnumToStringConverter + Assert.NotNull(mapping.Converter); + } + + [Fact] + public void FindMapping_ValueTuple_ResolvesFromClrType() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof((int, string))); + Assert.NotNull(mapping); + Assert.Equal(typeof((int, string)), mapping.ClrType); + Assert.Equal("Tuple(Int32, String)", mapping.StoreType); + } + + [Fact] + public void FindMapping_RefTuple_ResolvesFromClrType() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(Tuple)); + Assert.NotNull(mapping); + Assert.Equal(typeof(Tuple), mapping.ClrType); + Assert.Equal("Tuple(Int32, String)", mapping.StoreType); + } + + [Fact] + public void FindMapping_RefTuple_WithStoreType_ResolvesCorrectly() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(Tuple), "Tuple(Int32, String)"); + Assert.NotNull(mapping); + Assert.Equal(typeof(Tuple), mapping.ClrType); + } + + [Fact] + public void FindMapping_ValueTuple_WithStoreType_ResolvesCorrectly() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof((int, string)), "Tuple(Int32, String)"); + Assert.NotNull(mapping); + Assert.Equal(typeof((int, string)), mapping.ClrType); + } + + [Fact] + public void FindMapping_ListOfInt_ResolvesWithConverter() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(List)); + Assert.NotNull(mapping); + Assert.Equal(typeof(List), mapping.ClrType); + Assert.Equal("Array(Int32)", mapping.StoreType); + Assert.NotNull(mapping.Converter); + } + + [Fact] + public void FindMapping_ListOfInt_WithStoreType_ResolvesWithConverter() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(List), "Array(Int32)"); + Assert.NotNull(mapping); + Assert.Equal(typeof(List), mapping.ClrType); + Assert.Equal("Array(Int32)", mapping.StoreType); + Assert.NotNull(mapping.Converter); + } + + [Fact] + public void FindMapping_ClickHouseDecimal_ResolvesFromClrType() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(ClickHouseDecimal)); + Assert.NotNull(mapping); + Assert.Equal(typeof(ClickHouseDecimal), mapping.ClrType); + Assert.Contains("Decimal", mapping.StoreType); + } + + [Fact] + public void FindMapping_ClickHouseDecimal_WithStoreType() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(ClickHouseDecimal), "Decimal128(18)"); + Assert.NotNull(mapping); + Assert.Equal(typeof(ClickHouseDecimal), mapping.ClrType); + Assert.Equal("Decimal(38,18)", mapping.StoreType); + } + + [Fact] + public void FindMapping_ClrEnum_ConverterWorks() + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(typeof(TestEnum8))!; + // Verify the converter converts enum to string correctly + var converted = mapping.Converter!.ConvertToProvider(TestEnum8.b); + Assert.Equal("b", converted); + // And back + var back = mapping.Converter.ConvertFromProvider("b"); + Assert.Equal(TestEnum8.b, back); + } + + private static Microsoft.EntityFrameworkCore.Storage.IRelationalTypeMappingSource GetTypeMappingSource() + { + // Build a minimal DbContext to get a properly-configured type mapping source via DI + var builder = new DbContextOptionsBuilder(); + builder.UseClickHouse("Host=localhost;Protocol=http"); + using var ctx = new DbContext(builder.Options); + return ((IInfrastructure)ctx).Instance + .GetRequiredService(); + } +} + +#endregion diff --git a/test/EFCore.ClickHouse.Tests/FloatSpecialValueTests.cs b/test/EFCore.ClickHouse.Tests/FloatSpecialValueTests.cs index 5825bd9..3bfe284 100644 --- a/test/EFCore.ClickHouse.Tests/FloatSpecialValueTests.cs +++ b/test/EFCore.ClickHouse.Tests/FloatSpecialValueTests.cs @@ -42,15 +42,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class FloatSpecialFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = - new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -79,10 +75,7 @@ INSERT INTO float_specials VALUES await insertCmd.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } /// diff --git a/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs b/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs index 994dc7d..a6e2241 100644 --- a/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs +++ b/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs @@ -145,14 +145,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class InsertFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -208,10 +205,7 @@ ORDER BY id await cmd3.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class InsertIntegrationTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/MathTranslationTests.cs b/test/EFCore.ClickHouse.Tests/MathTranslationTests.cs index 12c4458..c12f4c5 100644 --- a/test/EFCore.ClickHouse.Tests/MathTranslationTests.cs +++ b/test/EFCore.ClickHouse.Tests/MathTranslationTests.cs @@ -44,14 +44,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class FloatFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -81,10 +78,7 @@ INSERT INTO float_test (id, label, val_f64, val_f32) VALUES await insertCmd.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class MathTranslationTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/ParameterizedTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/ParameterizedTypeMappingTests.cs index dbc7a6f..11deaed 100644 --- a/test/EFCore.ClickHouse.Tests/ParameterizedTypeMappingTests.cs +++ b/test/EFCore.ClickHouse.Tests/ParameterizedTypeMappingTests.cs @@ -70,14 +70,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class ParameterizedTypeFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -139,10 +136,7 @@ INSERT INTO fs_test (id, code) VALUES await insert3.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class ParameterizedTypeMappingTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/ReadQueryIntegrationTests.cs b/test/EFCore.ClickHouse.Tests/ReadQueryIntegrationTests.cs index fa12691..6810a94 100644 --- a/test/EFCore.ClickHouse.Tests/ReadQueryIntegrationTests.cs +++ b/test/EFCore.ClickHouse.Tests/ReadQueryIntegrationTests.cs @@ -44,14 +44,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class ClickHouseFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -85,10 +82,7 @@ INSERT INTO test_entities (id, name, age, is_active) VALUES await insertCmd.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class ReadQueryIntegrationTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/SharedContainer.cs b/test/EFCore.ClickHouse.Tests/SharedContainer.cs new file mode 100644 index 0000000..45aa85b --- /dev/null +++ b/test/EFCore.ClickHouse.Tests/SharedContainer.cs @@ -0,0 +1,58 @@ +using Testcontainers.ClickHouse; + +namespace EFCore.ClickHouse.Tests; + +/// +/// Provides a single shared ClickHouse container for all integration tests. +/// Each fixture gets an isolated database via . +/// +public static class SharedContainer +{ + private static readonly SemaphoreSlim Lock = new(1, 1); + private static ClickHouseContainer? _container; + private static string? _baseConnectionString; + private static int _dbCounter; + + /// + /// Returns a connection string pointing to a fresh, isolated database + /// on the shared ClickHouse container. + /// + public static async Task GetConnectionStringAsync() + { + await EnsureContainerAsync(); + + var dbName = $"test_{Interlocked.Increment(ref _dbCounter)}"; + + using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(_baseConnectionString!); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE DATABASE {dbName}"; + await cmd.ExecuteNonQueryAsync(); + + // Replace the default database in the connection string + return _baseConnectionString!.Contains("Database=") + ? System.Text.RegularExpressions.Regex.Replace(_baseConnectionString!, @"Database=[^;]*", $"Database={dbName}") + : _baseConnectionString + $";Database={dbName}"; + } + + private static async Task EnsureContainerAsync() + { + if (_baseConnectionString is not null) + return; + + await Lock.WaitAsync(); + try + { + if (_baseConnectionString is not null) + return; + + _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); + await _container.StartAsync(); + _baseConnectionString = _container.GetConnectionString(); + } + finally + { + Lock.Release(); + } + } +} diff --git a/test/EFCore.ClickHouse.Tests/StringMethodTranslationTests.cs b/test/EFCore.ClickHouse.Tests/StringMethodTranslationTests.cs index 6fc1bd6..cabe961 100644 --- a/test/EFCore.ClickHouse.Tests/StringMethodTranslationTests.cs +++ b/test/EFCore.ClickHouse.Tests/StringMethodTranslationTests.cs @@ -40,14 +40,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class StringTestFixture : IAsyncLifetime { - private readonly ClickHouseContainer _container = new ClickHouseBuilder("clickhouse/clickhouse-server:latest").Build(); - public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); + ConnectionString = await SharedContainer.GetConnectionStringAsync(); using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); await connection.OpenAsync(); @@ -77,10 +74,7 @@ INSERT INTO string_test (id, val) VALUES await insertCmd.ExecuteNonQueryAsync(); } - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } + public Task DisposeAsync() => Task.CompletedTask; } public class StringMethodTranslationTests : IClassFixture diff --git a/test/EFCore.ClickHouse.Tests/TypeMappingLiteralTests.cs b/test/EFCore.ClickHouse.Tests/TypeMappingLiteralTests.cs index f12f410..3bc2627 100644 --- a/test/EFCore.ClickHouse.Tests/TypeMappingLiteralTests.cs +++ b/test/EFCore.ClickHouse.Tests/TypeMappingLiteralTests.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Numerics; +using ClickHouse.Driver.Numerics; using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; using Xunit; @@ -110,4 +113,195 @@ public void Double_Null_GeneratesNullLiteral() var literal = mapping.GenerateSqlLiteral(null); Assert.Equal("NULL", literal); } + + // --- BigInteger (ClickHouseBigIntegerTypeMapping) --- + + [Fact] + public void BigInteger_GeneratesNumericLiteral() + { + var mapping = new ClickHouseBigIntegerTypeMapping("Int128"); + var literal = mapping.GenerateSqlLiteral(new BigInteger(123456789)); + Assert.Equal("123456789", literal); + } + + [Fact] + public void BigInteger_Negative_GeneratesNumericLiteral() + { + var mapping = new ClickHouseBigIntegerTypeMapping("Int256"); + var literal = mapping.GenerateSqlLiteral(new BigInteger(-42)); + Assert.Equal("-42", literal); + } + + [Fact] + public void BigInteger_Zero_GeneratesNumericLiteral() + { + var mapping = new ClickHouseBigIntegerTypeMapping("UInt128"); + var literal = mapping.GenerateSqlLiteral(BigInteger.Zero); + Assert.Equal("0", literal); + } + + [Fact] + public void BigInteger_Null_GeneratesNullLiteral() + { + var mapping = new ClickHouseBigIntegerTypeMapping(); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } + + // --- IPAddress (ClickHouseIPAddressTypeMapping) --- + + [Fact] + public void IPAddress_IPv4_GeneratesQuotedLiteral() + { + var mapping = new ClickHouseIPAddressTypeMapping("IPv4"); + var literal = mapping.GenerateSqlLiteral(IPAddress.Parse("127.0.0.1")); + Assert.Equal("'127.0.0.1'", literal); + } + + [Fact] + public void IPAddress_IPv6_GeneratesQuotedLiteral() + { + var mapping = new ClickHouseIPAddressTypeMapping("IPv6"); + var literal = mapping.GenerateSqlLiteral(IPAddress.IPv6Loopback); + Assert.Equal("'::1'", literal); + } + + [Fact] + public void IPAddress_Null_GeneratesNullLiteral() + { + var mapping = new ClickHouseIPAddressTypeMapping(); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } + + // --- BigDecimal (ClickHouseBigDecimalTypeMapping) --- + + [Fact] + public void BigDecimal_GeneratesDecimalLiteral() + { + var mapping = new ClickHouseBigDecimalTypeMapping(38, 18); + var literal = mapping.GenerateSqlLiteral(new ClickHouseDecimal(123.456m)); + Assert.Equal("123.456", literal); + } + + [Fact] + public void BigDecimal_Zero_GeneratesZeroLiteral() + { + var mapping = new ClickHouseBigDecimalTypeMapping(); + var literal = mapping.GenerateSqlLiteral(new ClickHouseDecimal(0m)); + Assert.Equal("0", literal); + } + + [Fact] + public void BigDecimal_Null_GeneratesNullLiteral() + { + var mapping = new ClickHouseBigDecimalTypeMapping(); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } + + // --- Array (ClickHouseArrayTypeMapping) --- + + [Fact] + public void Array_IntArray_GeneratesBracketLiteral() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseArrayTypeMapping(intMapping); + var literal = mapping.GenerateSqlLiteral(new[] { 1, 2, 3 }); + Assert.Equal("[1, 2, 3]", literal); + } + + [Fact] + public void Array_StringArray_GeneratesBracketLiteral() + { + var strMapping = new ClickHouseStringTypeMapping(); + var mapping = new ClickHouseArrayTypeMapping(strMapping); + var literal = mapping.GenerateSqlLiteral(new[] { "a", "b" }); + Assert.Equal("['a', 'b']", literal); + } + + [Fact] + public void Array_Empty_GeneratesEmptyBrackets() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseArrayTypeMapping(intMapping); + var literal = mapping.GenerateSqlLiteral(Array.Empty()); + Assert.Equal("[]", literal); + } + + [Fact] + public void Array_Null_GeneratesNullLiteral() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseArrayTypeMapping(intMapping); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } + + // --- Map (ClickHouseMapTypeMapping) --- + + [Fact] + public void Map_StringInt_GeneratesMapLiteral() + { + var strMapping = new ClickHouseStringTypeMapping(); + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseMapTypeMapping(strMapping, intMapping); + // Use a single-entry map to avoid order-dependent assertions + var dict = new Dictionary { ["a"] = 1 }; + var literal = mapping.GenerateSqlLiteral(dict); + Assert.Equal("map('a', 1)", literal); + } + + [Fact] + public void Map_Empty_GeneratesEmptyMapLiteral() + { + var strMapping = new ClickHouseStringTypeMapping(); + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseMapTypeMapping(strMapping, intMapping); + var dict = new Dictionary(); + var literal = mapping.GenerateSqlLiteral(dict); + Assert.Equal("map()", literal); + } + + [Fact] + public void Map_Null_GeneratesNullLiteral() + { + var strMapping = new ClickHouseStringTypeMapping(); + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var mapping = new ClickHouseMapTypeMapping(strMapping, intMapping); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } + + // --- Tuple (ClickHouseTupleTypeMapping) --- + + [Fact] + public void Tuple_ValueTuple_GeneratesParenLiteral() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var strMapping = new ClickHouseStringTypeMapping(); + var mapping = new ClickHouseTupleTypeMapping([intMapping, strMapping], useValueTuple: true); + var literal = mapping.GenerateSqlLiteral((1, "hello")); + Assert.Equal("(1, 'hello')", literal); + } + + [Fact] + public void Tuple_RefTuple_GeneratesParenLiteral() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var strMapping = new ClickHouseStringTypeMapping(); + var mapping = new ClickHouseTupleTypeMapping([intMapping, strMapping], useValueTuple: false); + var literal = mapping.GenerateSqlLiteral(Tuple.Create(42, "world")); + Assert.Equal("(42, 'world')", literal); + } + + [Fact] + public void Tuple_Null_GeneratesNullLiteral() + { + var intMapping = new ClickHouseIntegerTypeMapping("Int32", typeof(int), System.Data.DbType.Int32); + var strMapping = new ClickHouseStringTypeMapping(); + var mapping = new ClickHouseTupleTypeMapping([intMapping, strMapping]); + var literal = mapping.GenerateSqlLiteral(null); + Assert.Equal("NULL", literal); + } }