From d82cd9594f5410e4c1b80311d2cfc5aa6b00cb43 Mon Sep 17 00:00:00 2001 From: Denis Ivanov Date: Sat, 3 Jan 2026 00:07:15 +0200 Subject: [PATCH 1/4] Map char to FixedString(1 or 2) --- .../ClickHouseMemberTranslatorProvider.cs | 2 +- .../ClickHouseMethodCallTranslatorProvider.cs | 2 +- .../Internal/ClickHouseStringTranslator.cs | 22 ++++++-- .../Internal/ClickHouseTypeMappingSource.cs | 10 ++++ .../ClickHouseFixedStringTypeMapping.cs | 50 +++++++++++++++---- .../ValueConvertersEndToEndClickHouseTest.cs | 21 ++++---- 6 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMemberTranslatorProvider.cs b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMemberTranslatorProvider.cs index 94d51d7..d16b791 100644 --- a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMemberTranslatorProvider.cs +++ b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMemberTranslatorProvider.cs @@ -9,7 +9,7 @@ public ClickHouseMemberTranslatorProvider(RelationalMemberTranslatorProviderDepe : base(dependencies) { AddTranslators([ - new ClickHouseStringTranslator(dependencies.SqlExpressionFactory), + new ClickHouseStringTranslator(dependencies.SqlExpressionFactory, typeMappingSource), new ClickHouseArrayTranslator(dependencies.SqlExpressionFactory), new ClickHouseMathTranslator(dependencies.SqlExpressionFactory, typeMappingSource), new ClickHouseConvertTranslator(dependencies.SqlExpressionFactory), diff --git a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMethodCallTranslatorProvider.cs b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMethodCallTranslatorProvider.cs index c5c2629..1f909e7 100644 --- a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMethodCallTranslatorProvider.cs +++ b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseMethodCallTranslatorProvider.cs @@ -10,7 +10,7 @@ public ClickHouseMethodCallTranslatorProvider(RelationalMethodCallTranslatorProv AddTranslators([ new ClickHouseArrayTranslator(dependencies.SqlExpressionFactory), new ClickHouseGuidTranslator(dependencies.SqlExpressionFactory), - new ClickHouseStringTranslator(dependencies.SqlExpressionFactory), + new ClickHouseStringTranslator(dependencies.SqlExpressionFactory, dependencies.RelationalTypeMappingSource), new ClickHouseMathTranslator(dependencies.SqlExpressionFactory, dependencies.RelationalTypeMappingSource), new ClickHouseConvertTranslator(dependencies.SqlExpressionFactory), new ClickHouseDateTimeMethodTranslator(dependencies.SqlExpressionFactory), diff --git a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs index cc69460..e6e124b 100644 --- a/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs +++ b/src/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs @@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -97,9 +99,13 @@ public class ClickHouseStringTranslator : IMethodCallTranslator, IMemberTranslat .GetRuntimeMethod(nameof(string.Replace), [typeof(string), typeof(string)])!; private readonly ClickHouseSqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _relationalTypeMappingSource; - public ClickHouseStringTranslator([NotNull]ISqlExpressionFactory sqlExpressionFactory) + public ClickHouseStringTranslator( + [NotNull]ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource relationalTypeMappingSource) { + _relationalTypeMappingSource = relationalTypeMappingSource; _sqlExpressionFactory = (ClickHouseSqlExpressionFactory)sqlExpressionFactory; } @@ -256,7 +262,10 @@ public ClickHouseStringTranslator([NotNull]ISqlExpressionFactory sqlExpressionFa [argument, _sqlExpressionFactory.Constant(1), _sqlExpressionFactory.Constant(1)], nullable: true, argumentsPropagateNullability: [true, true, true], - method.ReturnType); + method.ReturnType, + typeMapping: (RelationalTypeMapping)_relationalTypeMappingSource + .FindMapping(typeof(string)) + !.WithComposedConverter(new CharToStringConverter())); } if (LastOrDefaultWithoutArgs.Equals(method)) @@ -276,7 +285,8 @@ public ClickHouseStringTranslator([NotNull]ISqlExpressionFactory sqlExpressionFa ], nullable: true, argumentsPropagateNullability: [true, true, true], - method.ReturnType); + method.ReturnType, + typeMapping: _relationalTypeMappingSource.FindMapping(method.ReturnType)); } if (SubstringWithStartIndex.Equals(method)) @@ -288,7 +298,8 @@ public ClickHouseStringTranslator([NotNull]ISqlExpressionFactory sqlExpressionFa [instance!, _sqlExpressionFactory.Add(startIndex, _sqlExpressionFactory.Constant(1))], nullable: true, argumentsPropagateNullability: [true, true], - method.ReturnType); + method.ReturnType, + typeMapping: _relationalTypeMappingSource.FindMapping(method.ReturnType)); } if (SubstringWithIndexAndLength.Equals(method)) @@ -301,7 +312,8 @@ public ClickHouseStringTranslator([NotNull]ISqlExpressionFactory sqlExpressionFa [instance!, _sqlExpressionFactory.Add(startIndex, _sqlExpressionFactory.Constant(1)), length], nullable: true, argumentsPropagateNullability: [true, true, true], - method.ReturnType); + method.ReturnType, + typeMapping: _relationalTypeMappingSource.FindMapping(method.ReturnType)); } if (IndexOf.Equals(method)) diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs index 33aa6f3..a793780 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs @@ -192,6 +192,16 @@ public ClickHouseTypeMappingSource(TypeMappingSourceDependencies dependencies, R mappingInfo.Size.Value); } + if (mappingInfo.ClrType == typeof(char)) + { + var isUnicode = mappingInfo.IsUnicode ?? true; + + return new ClickHouseFixedStringTypeMapping( + mappingInfo.ClrType!, + isUnicode, + isUnicode ? 2 : 1); + } + if (mappingInfo.ClrType != null && ClrTypeMappings.TryGetValue(mappingInfo.ClrType, out var map)) { return map; diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs index 40871fc..5ba9cc3 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Storage; +using ClickHouse.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; @@ -18,13 +19,7 @@ public ClickHouseFixedStringTypeMapping( new RelationalTypeMappingParameters( new CoreTypeMappingParameters( clrType: clrType, - converter: new StringToBytesConverter( - unicode ? Encoding.UTF8 : Encoding.ASCII, - new ConverterMappingHints( - size: size, - precision: null, - scale: null, - unicode: unicode)), + converter: GetConverter(clrType, unicode, size), jsonValueReaderWriter: clrType == typeof(char) ? JsonCharReaderWriter.Instance : clrType == typeof(string) @@ -32,7 +27,7 @@ public ClickHouseFixedStringTypeMapping( : throw new ArgumentException("Argument type must be char or string", nameof(clrType))), storeType: "FixedString", storeTypePostfix: StoreTypePostfix.Size, - dbType: System.Data.DbType.Binary, + dbType: unicode ? System.Data.DbType.StringFixedLength : System.Data.DbType.AnsiStringFixedLength, unicode: unicode, size: size, fixedLength: true, @@ -50,8 +45,45 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p return new ClickHouseFixedStringTypeMapping(parameters); } + protected override void ConfigureParameter(DbParameter parameter) + { + parameter.SetStoreType(StoreType); + } + public override MethodInfo GetDataReaderMethod() { return typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetValue), [typeof(int)])!; } + + private static ValueConverter GetConverter(Type clrType, bool unicode, int size) + { + var mappingHints = new ConverterMappingHints( + size: size, + precision: null, + scale: null, + unicode: unicode); + + var stringConverter = new StringToBytesConverter( + unicode ? Encoding.UTF8 : Encoding.ASCII, + mappingHints); + + if (clrType == typeof(string)) + { + return stringConverter; + } + + return clrType == typeof(char) + ? new CharToStringConverter(mappingHints).ComposeWith(stringConverter) + : throw new ArgumentException("Argument type must be char or string", nameof(clrType)); + } + + protected override string GenerateNonNullSqlLiteral(object value) + { + if (value is byte[]) + { + return "'" + Converter!.ConvertFromProvider(value) + "'"; + } + + return base.GenerateNonNullSqlLiteral(value); + } } diff --git a/test/EntityFrameworkCore.ClickHouse.FunctionalTests/ValueConvertersEndToEndClickHouseTest.cs b/test/EntityFrameworkCore.ClickHouse.FunctionalTests/ValueConvertersEndToEndClickHouseTest.cs index db457c4..c5d5b2f 100644 --- a/test/EntityFrameworkCore.ClickHouse.FunctionalTests/ValueConvertersEndToEndClickHouseTest.cs +++ b/test/EntityFrameworkCore.ClickHouse.FunctionalTests/ValueConvertersEndToEndClickHouseTest.cs @@ -21,10 +21,10 @@ public override Task Can_insert_and_read_back_with_conversions(int[] valueOrder) { return base.Can_insert_and_read_back_with_conversions(valueOrder); } - + [ConditionalTheory] - [InlineData(nameof(ConvertingEntity.BoolAsChar), "String", false)] - [InlineData(nameof(ConvertingEntity.BoolAsNullableChar), "String", false)] + [InlineData(nameof(ConvertingEntity.BoolAsChar), "FixedString(2)", false)] + [InlineData(nameof(ConvertingEntity.BoolAsNullableChar), "FixedString(2)", false)] [InlineData(nameof(ConvertingEntity.BoolAsString), "String", false)] [InlineData(nameof(ConvertingEntity.BoolAsInt), "Int32", false)] [InlineData(nameof(ConvertingEntity.BoolAsNullableString), "String", false)] @@ -67,8 +67,8 @@ public override Task Can_insert_and_read_back_with_conversions(int[] valueOrder) [InlineData(nameof(ConvertingEntity.StringToNullableBool), "Bool", false)] [InlineData(nameof(ConvertingEntity.StringToBytes), "Array(UInt8)", false)] [InlineData(nameof(ConvertingEntity.StringToNullableBytes), "Array(UInt8)", false)] - [InlineData(nameof(ConvertingEntity.StringToChar), "String", false)] - [InlineData(nameof(ConvertingEntity.StringToNullableChar), "String", false)] + [InlineData(nameof(ConvertingEntity.StringToChar), "FixedString(2)", false)] + [InlineData(nameof(ConvertingEntity.StringToNullableChar), "FixedString(2)", false)] [InlineData(nameof(ConvertingEntity.StringToDateTime), "DateTime", false)] [InlineData(nameof(ConvertingEntity.StringToNullableDateTime), "DateTime", false)] [InlineData(nameof(ConvertingEntity.StringToDateTimeOffset), "String", false)] @@ -89,8 +89,8 @@ public override Task Can_insert_and_read_back_with_conversions(int[] valueOrder) [InlineData(nameof(ConvertingEntity.UriToNullableString), "String", false)] [InlineData(nameof(ConvertingEntity.NullableCharAsString), "String", true)] [InlineData(nameof(ConvertingEntity.NullableCharAsNullableString), "String", true)] - [InlineData(nameof(ConvertingEntity.NullableBoolAsChar), "String", true)] - [InlineData(nameof(ConvertingEntity.NullableBoolAsNullableChar), "String", true)] + [InlineData(nameof(ConvertingEntity.NullableBoolAsChar), "FixedString(2)", true)] + [InlineData(nameof(ConvertingEntity.NullableBoolAsNullableChar), "FixedString(2)", true)] [InlineData(nameof(ConvertingEntity.NullableBoolAsString), "String", true)] [InlineData(nameof(ConvertingEntity.NullableBoolAsNullableString), "String", true)] [InlineData(nameof(ConvertingEntity.NullableBoolAsInt), "Int32", true)] @@ -131,8 +131,8 @@ public override Task Can_insert_and_read_back_with_conversions(int[] valueOrder) [InlineData(nameof(ConvertingEntity.NullableStringToNullableBool), "Bool", true)] [InlineData(nameof(ConvertingEntity.NullableStringToBytes), "Array(UInt8)", true)] [InlineData(nameof(ConvertingEntity.NullableStringToNullableBytes), "Array(UInt8)", true)] - [InlineData(nameof(ConvertingEntity.NullableStringToChar), "String", true)] - [InlineData(nameof(ConvertingEntity.NullableStringToNullableChar), "String", true)] + [InlineData(nameof(ConvertingEntity.NullableStringToChar), "FixedString(2)", true)] + [InlineData(nameof(ConvertingEntity.NullableStringToNullableChar), "FixedString(2)", true)] [InlineData(nameof(ConvertingEntity.NullableStringToDateTime), "DateTime", true)] [InlineData(nameof(ConvertingEntity.NullableStringToNullableDateTime), "DateTime", true)] [InlineData(nameof(ConvertingEntity.NullableStringToDateTimeOffset), "String", true)] @@ -168,8 +168,7 @@ public virtual void Properties_with_conversions_map_to_appropriately_null_column public class ValueConvertersEndToEndClickHouseFixture : ValueConvertersEndToEndFixtureBase { - protected override ITestStoreFactory TestStoreFactory - => ClickHouseTestStoreFactory.Instance; + protected override ITestStoreFactory TestStoreFactory => ClickHouseTestStoreFactory.Instance; protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) { From 0f5a54d79f6ce700b6af8d7e59486400290fe14b Mon Sep 17 00:00:00 2001 From: Denis Ivanov Date: Sat, 3 Jan 2026 09:43:53 +0200 Subject: [PATCH 2/4] Char value converters --- .../Internal/ClickHouseTypeMappingSource.cs | 2 +- .../Mapping/ClickHouseCharTypeMapping.cs | 45 ---------- .../ClickHouseFixedStringTypeMapping.cs | 14 ++- .../ClickHouseCharToStringConverter.cs | 14 +++ .../ClickHouseCharValueConverter.cs | 15 ---- .../ClickHouseStringToBytesConverter.cs | 89 +++++++++++++++++++ 6 files changed, 114 insertions(+), 65 deletions(-) delete mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseCharTypeMapping.cs create mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs delete mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharValueConverter.cs create mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs index a793780..c4e4ebc 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs @@ -12,7 +12,7 @@ public class ClickHouseTypeMappingSource : RelationalTypeMappingSource private static readonly RelationalTypeMapping StringTypeMapping = new ClickHouseStringTypeMapping(); private static readonly RelationalTypeMapping BoolTypeMapping = new ClickHouseBoolTypeMapping(); private static readonly RelationalTypeMapping ByteTypeMapping = new ClickHouseByteTypeMapping(); - private static readonly RelationalTypeMapping CharTypeMapping = new ClickHouseCharTypeMapping(); + private static readonly RelationalTypeMapping CharTypeMapping = new ClickHouseFixedStringTypeMapping(typeof(char), true, 2); private static readonly RelationalTypeMapping Int8TypeMapping = new ClickHouseInt8TypeMapping(); private static readonly RelationalTypeMapping Int16TypeMapping = new ClickHouseInt16TypeMapping(); private static readonly RelationalTypeMapping Int32TypeMapping = new ClickHouseInt32TypeMapping(); diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseCharTypeMapping.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseCharTypeMapping.cs deleted file mode 100644 index d17c997..0000000 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseCharTypeMapping.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ClickHouse.EntityFrameworkCore.Extensions; -using ClickHouse.EntityFrameworkCore.Storage.ValueConversation; -using Microsoft.EntityFrameworkCore.Storage; -using System.Data.Common; -using System.Reflection; - -namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; - -public class ClickHouseCharTypeMapping : CharTypeMapping -{ - public ClickHouseCharTypeMapping() - : base( - new RelationalTypeMappingParameters( - new CoreTypeMappingParameters( - typeof(char), - new ClickHouseCharValueConverter()), - "String", - StoreTypePostfix.None, - System.Data.DbType.StringFixedLength, - true, - 1, - true, - 0, 0)) - { - } - - protected ClickHouseCharTypeMapping(RelationalTypeMappingParameters parameters) : base(parameters) - { - } - - protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - { - return new ClickHouseCharTypeMapping(parameters); - } - - public override MethodInfo GetDataReaderMethod() - { - return typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetString), [typeof(int)])!; - } - - protected override void ConfigureParameter(DbParameter parameter) - { - parameter.SetStoreType(StoreType); - } -} diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs index 5ba9cc3..728de71 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs @@ -1,4 +1,5 @@ using ClickHouse.EntityFrameworkCore.Extensions; +using ClickHouse.EntityFrameworkCore.Storage.ValueConversation; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -63,7 +64,7 @@ private static ValueConverter GetConverter(Type clrType, bool unicode, int size) scale: null, unicode: unicode); - var stringConverter = new StringToBytesConverter( + var stringConverter = new ClickHouseStringToBytesConverter( unicode ? Encoding.UTF8 : Encoding.ASCII, mappingHints); @@ -72,9 +73,14 @@ private static ValueConverter GetConverter(Type clrType, bool unicode, int size) return stringConverter; } - return clrType == typeof(char) - ? new CharToStringConverter(mappingHints).ComposeWith(stringConverter) - : throw new ArgumentException("Argument type must be char or string", nameof(clrType)); + if (clrType == typeof(char)) + { + var charToStringConverter = new ClickHouseCharToStringConverter(mappingHints); + var composed = charToStringConverter.ComposeWith(stringConverter); + return composed; + } + + throw new ArgumentException("Argument type must be char or string", nameof(clrType)); } protected override string GenerateNonNullSqlLiteral(object value) diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs new file mode 100644 index 0000000..c929e26 --- /dev/null +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; + +public class ClickHouseCharToStringConverter : ValueConverter +{ + public ClickHouseCharToStringConverter(ConverterMappingHints mappingHints = null) + : base( + c => c == null ? null : c.ToString(), + s => string.IsNullOrEmpty(s) ? null : s[0], + mappingHints) + { + } +} diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharValueConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharValueConverter.cs deleted file mode 100644 index a4269e1..0000000 --- a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharValueConverter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System; - -namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; - -public class ClickHouseCharValueConverter : ValueConverter -{ - public ClickHouseCharValueConverter() : - base( - c => Convert.ToString(c), - s => Convert.ToChar(s), - new ConverterMappingHints(1, 0, 0, true)) - { - } -} diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs new file mode 100644 index 0000000..832a89e --- /dev/null +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; + +namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; + +public class ClickHouseStringToBytesConverter : ValueConverter +{ + private static readonly MethodInfo EncodingGetBytesMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetBytes), [typeof(string)])!; + + private static readonly MethodInfo EncodingGetStringMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetString), [typeof(byte[])])!; + + private static readonly MethodInfo EncodingGetEncodingMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetEncoding), [typeof(int)])!; + + private static readonly MethodInfo StringGetCharsMethodInfo = + typeof(string).GetProperty("Chars", [typeof(int)])!.GetGetMethod()!; + + public ClickHouseStringToBytesConverter( + Encoding encoding, + ConverterMappingHints mappingHints = null) + : base( + FromProvider(encoding), + ToProvider(encoding), + mappingHints) + { + } + + public static ValueConverterInfo DefaultInfo { get; } + = new(typeof(string), typeof(byte[]), i => new StringToBytesConverter(Encoding.UTF8, i.MappingHints)); + + private static Expression> FromProvider(Encoding encoding) + { + // v => v == null || v.Length == 0 || (v.Length == 1 && v[0] == '\0') ? null : encoding.GetBytes(v) + var prm = Expression.Parameter(typeof(string), "v"); + + var nullCheck = Expression.Equal(prm, Expression.Constant(null)); + var lengthZeroCheck = Expression.Equal( + Expression.PropertyOrField(prm, nameof(string.Length)), + Expression.Constant(0)); + + var singleNullCharCheck = Expression.AndAlso( + Expression.Equal( + Expression.PropertyOrField(prm, nameof(string.Length)), + Expression.Constant(1)), + Expression.Equal( + Expression.Call(prm, StringGetCharsMethodInfo, Expression.Constant(0)), + Expression.Constant('\0'))); + + var shouldReturnNull = Expression.OrElse( + Expression.OrElse(nullCheck, lengthZeroCheck), + singleNullCharCheck); + + var encodeBytes = Expression.Call( + Expression.Call( + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)), + EncodingGetBytesMethodInfo, + prm); + + var result = Expression.Lambda>( + Expression.Condition( + shouldReturnNull, + Expression.Constant(null, typeof(byte[])), + encodeBytes), + prm); + + return result; + } + + private static Expression> ToProvider(Encoding encoding) + { + // v => encoding.GetString(v!) + var prm = Expression.Parameter(typeof(byte[]), "v"); + var result = Expression.Lambda>( + Expression.Call( + Expression.Call( + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)), + EncodingGetStringMethodInfo, prm), + prm); + + return result; + } +} From 4d8dba254315c53323fbef5c3e339e2872859443 Mon Sep 17 00:00:00 2001 From: Denis Ivanov Date: Sat, 3 Jan 2026 15:30:38 +0200 Subject: [PATCH 3/4] Fix FixedString conversion to char --- .../ClickHouseCharToStringConverter.cs | 4 +- .../ClickHouseStringToBytesConverter.cs | 90 +++++++++---------- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs index c929e26..166553a 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToStringConverter.cs @@ -2,9 +2,9 @@ namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; -public class ClickHouseCharToStringConverter : ValueConverter +public class ClickHouseCharToStringConverter : ValueConverter { - public ClickHouseCharToStringConverter(ConverterMappingHints mappingHints = null) + public ClickHouseCharToStringConverter(ConverterMappingHints? mappingHints = null) : base( c => c == null ? null : c.ToString(), s => string.IsNullOrEmpty(s) ? null : s[0], diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs index 832a89e..ee4456f 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs @@ -1,12 +1,13 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; +using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Text; namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; -public class ClickHouseStringToBytesConverter : ValueConverter +public class ClickHouseStringToBytesConverter : ValueConverter { private static readonly MethodInfo EncodingGetBytesMethodInfo = typeof(Encoding).GetMethod(nameof(Encoding.GetBytes), [typeof(string)])!; @@ -17,73 +18,66 @@ public class ClickHouseStringToBytesConverter : ValueConverter private static readonly MethodInfo EncodingGetEncodingMethodInfo = typeof(Encoding).GetMethod(nameof(Encoding.GetEncoding), [typeof(int)])!; - private static readonly MethodInfo StringGetCharsMethodInfo = - typeof(string).GetProperty("Chars", [typeof(int)])!.GetGetMethod()!; - public ClickHouseStringToBytesConverter( Encoding encoding, - ConverterMappingHints mappingHints = null) + ConverterMappingHints? mappingHints = null) : base( - FromProvider(encoding), ToProvider(encoding), + FromProvider(encoding), mappingHints) { } - public static ValueConverterInfo DefaultInfo { get; } - = new(typeof(string), typeof(byte[]), i => new StringToBytesConverter(Encoding.UTF8, i.MappingHints)); - - private static Expression> FromProvider(Encoding encoding) + private static Expression> ToProvider(Encoding encoding) { - // v => v == null || v.Length == 0 || (v.Length == 1 && v[0] == '\0') ? null : encoding.GetBytes(v) + // v => encoding.GetBytes(v!), var prm = Expression.Parameter(typeof(string), "v"); - - var nullCheck = Expression.Equal(prm, Expression.Constant(null)); - var lengthZeroCheck = Expression.Equal( - Expression.PropertyOrField(prm, nameof(string.Length)), - Expression.Constant(0)); - - var singleNullCharCheck = Expression.AndAlso( - Expression.Equal( - Expression.PropertyOrField(prm, nameof(string.Length)), - Expression.Constant(1)), - Expression.Equal( - Expression.Call(prm, StringGetCharsMethodInfo, Expression.Constant(0)), - Expression.Constant('\0'))); - - var shouldReturnNull = Expression.OrElse( - Expression.OrElse(nullCheck, lengthZeroCheck), - singleNullCharCheck); - - var encodeBytes = Expression.Call( - Expression.Call( - EncodingGetEncodingMethodInfo, - Expression.Constant(encoding.CodePage)), - EncodingGetBytesMethodInfo, - prm); - var result = Expression.Lambda>( - Expression.Condition( - shouldReturnNull, - Expression.Constant(null, typeof(byte[])), - encodeBytes), + Expression.Call( + Expression.Call( + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)), + EncodingGetBytesMethodInfo, prm), prm); return result; } - private static Expression> ToProvider(Encoding encoding) + private static Expression> FromProvider(Encoding encoding) { - // v => encoding.GetString(v!) + // v => v == null || v.Length == 0 || Array.TrueForAll(v, b => b == 0) ? null : encoding.GetString(v) var prm = Expression.Parameter(typeof(byte[]), "v"); - var result = Expression.Lambda>( + + var isNullCheck = Expression.Equal(prm, Expression.Constant(null, typeof(byte[]))); + var lengthProperty = Expression.Property(prm, nameof(Array.Length)); + var isEmptyCheck = Expression.Equal(lengthProperty, Expression.Constant(0)); + + // Array.TrueForAll(v, b => b == 0) + var trueForAllMethod = typeof(Array) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m is { Name: nameof(Array.TrueForAll), IsGenericMethodDefinition: true } && m.GetGenericArguments().Length == 1) + .MakeGenericMethod(typeof(byte)); + + var byteParam = Expression.Parameter(typeof(byte), "b"); + var predicate = Expression.Lambda>( + Expression.Equal(byteParam, Expression.Constant((byte)0)), + byteParam); + var allZeroCheck = Expression.Call(trueForAllMethod, prm, predicate); + + var conditionCheck = Expression.OrElse( + Expression.OrElse(isNullCheck, isEmptyCheck), + allZeroCheck); + + var nullConstant = Expression.Constant(null, typeof(string)); + var getStringCall = Expression.Call( Expression.Call( - Expression.Call( - EncodingGetEncodingMethodInfo, - Expression.Constant(encoding.CodePage)), - EncodingGetStringMethodInfo, prm), - prm); + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)), + EncodingGetStringMethodInfo, prm); + + var conditionalExpression = Expression.Condition(conditionCheck, nullConstant, getStringCall); + var result = Expression.Lambda>(conditionalExpression, prm); return result; } } From c31561d5b401eea69f9a3d510039a0dc5cc0ea7c Mon Sep 17 00:00:00 2001 From: Denis Ivanov Date: Mon, 12 Jan 2026 16:44:16 +0200 Subject: [PATCH 4/4] FixedString draft --- .../ClickHouseFixedStringTypeMapping.cs | 65 +++++++++++---- .../ClickHouseCharToBytesConverter.cs | 60 ++++++++++++++ .../ClickHouseNullableCharToBytesConverter.cs | 80 +++++++++++++++++++ .../ClickHouseStringToBytesConverter.cs | 31 ++----- 4 files changed, 196 insertions(+), 40 deletions(-) create mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToBytesConverter.cs create mode 100644 src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseNullableCharToBytesConverter.cs diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs index 728de71..918ec21 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; using System.Data.Common; +using System.Linq; +using System.Linq.Expressions; using System.Reflection; using System.Text; @@ -64,23 +66,15 @@ private static ValueConverter GetConverter(Type clrType, bool unicode, int size) scale: null, unicode: unicode); - var stringConverter = new ClickHouseStringToBytesConverter( - unicode ? Encoding.UTF8 : Encoding.ASCII, - mappingHints); + var encoding = unicode ? Encoding.UTF8 : Encoding.ASCII; - if (clrType == typeof(string)) + return clrType switch { - return stringConverter; - } - - if (clrType == typeof(char)) - { - var charToStringConverter = new ClickHouseCharToStringConverter(mappingHints); - var composed = charToStringConverter.ComposeWith(stringConverter); - return composed; - } - - throw new ArgumentException("Argument type must be char or string", nameof(clrType)); + var t when t == typeof(char) => new ClickHouseCharToBytesConverter(encoding, mappingHints), + var t when t == typeof(char?) => new ClickHouseNullableCharToBytesConverter(encoding, mappingHints), + var t when t == typeof(string) => new ClickHouseStringToBytesConverter(encoding, mappingHints), + _ => throw new ArgumentException("Argument type must be char, char? or string", nameof(clrType)) + }; } protected override string GenerateNonNullSqlLiteral(object value) @@ -92,4 +86,45 @@ protected override string GenerateNonNullSqlLiteral(object value) return base.GenerateNonNullSqlLiteral(value); } + + // File: `src/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseFixedStringTypeMapping.cs` + public override Expression CustomizeDataReaderExpression(Expression expression) + { + // expression is (object)reader.GetValue(ordinal); we need byte[] for checks + var byteArrayExpr = Expression.Convert(expression, typeof(byte[])); + + var nullCheck = Expression.Equal(byteArrayExpr, Expression.Constant(null, typeof(byte[]))); + var lengthProperty = Expression.Property(byteArrayExpr, nameof(Array.Length)); + var lengthZeroCheck = Expression.Equal(lengthProperty, Expression.Constant(0)); + + // Array.TrueForAll(v, b => b == 0) + var trueForAllMethod = typeof(Array) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(m => + m.Name == nameof(Array.TrueForAll) && + m.IsGenericMethodDefinition && + m.GetGenericArguments().Length == 1 && + m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(byte)); + + var b = Expression.Parameter(typeof(byte), "b"); + var predicate = Expression.Lambda>( + Expression.Equal(b, Expression.Constant((byte)0)), + b); + + var allZeroCheck = Expression.Call(trueForAllMethod, byteArrayExpr, predicate); + + // return null only for (null || empty || all-zero) + var condition = Expression.OrElse(Expression.OrElse(nullCheck, lengthZeroCheck), allZeroCheck); + + // keep type as object for EF shaper, but return the byte[] value in the "else" branch + var result = Expression.Condition( + condition, + Expression.Constant(null, typeof(object)), + Expression.Convert(byteArrayExpr, typeof(object))); + + return result; + } + + } diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToBytesConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToBytesConverter.cs new file mode 100644 index 0000000..8f2c5a9 --- /dev/null +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseCharToBytesConverter.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; + +namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; + +public sealed class ClickHouseCharToBytesConverter : ValueConverter +{ + private static readonly MethodInfo EncodingGetBytesMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetBytes), [typeof(string)])!; + + private static readonly MethodInfo EncodingGetStringMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetString), [typeof(byte[])])!; + + private static readonly MethodInfo EncodingGetEncodingMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetEncoding), [typeof(int)])!; + + private static readonly MethodInfo StringGetCharsMethodInfo = + typeof(string).GetMethod("get_Chars", [typeof(int)])!; + + public ClickHouseCharToBytesConverter(Encoding encoding, ConverterMappingHints mappingHints) + : base(ToProvider(encoding), FromProvider(encoding), mappingHints) + { + } + + private static Expression> ToProvider(Encoding encoding) + { + // c => Encoding.GetEncoding(cp).GetBytes(c.ToString()) + var prm = Expression.Parameter(typeof(char), "c"); + var toStringMethod = typeof(char).GetMethod(nameof(char.ToString), Type.EmptyTypes)!; + + var getEncodingCall = Expression.Call( + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)); + + var getBytesCall = Expression.Call( + getEncodingCall, + EncodingGetBytesMethodInfo, + Expression.Call(prm, toStringMethod)); + + return Expression.Lambda>(getBytesCall, prm); + } + + private static Expression> FromProvider(Encoding encoding) + { + // v => Encoding.GetEncoding(cp).GetString(v)[0] + var prm = Expression.Parameter(typeof(byte[]), "v"); + + var getStringCall = Expression.Call( + Expression.Call(EncodingGetEncodingMethodInfo, Expression.Constant(encoding.CodePage)), + EncodingGetStringMethodInfo, + prm); + + var getChar = Expression.Call(getStringCall, StringGetCharsMethodInfo, Expression.Constant(0)); + + return Expression.Lambda>(getChar, prm); + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseNullableCharToBytesConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseNullableCharToBytesConverter.cs new file mode 100644 index 0000000..bb5f002 --- /dev/null +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseNullableCharToBytesConverter.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; + +namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; + +public sealed class ClickHouseNullableCharToBytesConverter : ValueConverter +{ + private static readonly MethodInfo EncodingGetBytesMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetBytes), [typeof(string)])!; + + private static readonly MethodInfo EncodingGetStringMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetString), [typeof(byte[])])!; + + private static readonly MethodInfo EncodingGetEncodingMethodInfo = + typeof(Encoding).GetMethod(nameof(Encoding.GetEncoding), [typeof(int)])!; + + private static readonly MethodInfo StringGetCharsMethodInfo = + typeof(string).GetMethod("get_Chars", [typeof(int)])!; + + public ClickHouseNullableCharToBytesConverter(Encoding encoding, ConverterMappingHints mappingHints) + : base(ToProvider(encoding), FromProvider(encoding), mappingHints) + { + } + + private static Expression> ToProvider(Encoding encoding) + { + // c => c.HasValue ? Encoding.GetEncoding(cp).GetBytes(c.Value.ToString()) : null + var prm = Expression.Parameter(typeof(char?), "c"); + + var hasValue = Expression.Property(prm, nameof(Nullable.HasValue)); + var value = Expression.Property(prm, nameof(Nullable.Value)); + var toStringMethod = typeof(char).GetMethod(nameof(char.ToString), Type.EmptyTypes)!; + + var getEncodingCall = Expression.Call( + EncodingGetEncodingMethodInfo, + Expression.Constant(encoding.CodePage)); + + var getBytesCall = Expression.Call( + getEncodingCall, + EncodingGetBytesMethodInfo, + Expression.Call(value, toStringMethod)); + + var conditional = Expression.Condition( + hasValue, + Expression.Convert(getBytesCall, typeof(byte[])), + Expression.Constant(null, typeof(byte[]))); + + return Expression.Lambda>(conditional, prm); + } + + private static Expression> FromProvider(Encoding encoding) + { + // v => v == null || v.Length == 0 ? null : (char?)Encoding.GetEncoding(cp).GetString(v)[0] + var prm = Expression.Parameter(typeof(byte[]), "v"); + + var isNull = Expression.Equal(prm, Expression.Constant(null, typeof(byte[]))); + + var lengthProperty = Expression.Property(prm, nameof(Array.Length)); + var isEmpty = Expression.Equal(lengthProperty, Expression.Constant(0)); + + var nullConstant = Expression.Constant(null, typeof(char?)); + + var getStringCall = Expression.Call( + Expression.Call(EncodingGetEncodingMethodInfo, Expression.Constant(encoding.CodePage)), + EncodingGetStringMethodInfo, + prm); + + var getChar = Expression.Convert( + Expression.Call(getStringCall, StringGetCharsMethodInfo, Expression.Constant(0)), + typeof(char?)); + + var condition = Expression.OrElse(isNull, isEmpty); + var conditional = Expression.Condition(condition, nullConstant, getChar); + + return Expression.Lambda>(conditional, prm); + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs index ee4456f..c72e0ed 100644 --- a/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs +++ b/src/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseStringToBytesConverter.cs @@ -45,39 +45,20 @@ public ClickHouseStringToBytesConverter( private static Expression> FromProvider(Encoding encoding) { - // v => v == null || v.Length == 0 || Array.TrueForAll(v, b => b == 0) ? null : encoding.GetString(v) + // v => v == null ? null : Encoding.GetEncoding(cp).GetString(v) var prm = Expression.Parameter(typeof(byte[]), "v"); var isNullCheck = Expression.Equal(prm, Expression.Constant(null, typeof(byte[]))); - var lengthProperty = Expression.Property(prm, nameof(Array.Length)); - var isEmptyCheck = Expression.Equal(lengthProperty, Expression.Constant(0)); - - // Array.TrueForAll(v, b => b == 0) - var trueForAllMethod = typeof(Array) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .First(m => m is { Name: nameof(Array.TrueForAll), IsGenericMethodDefinition: true } && m.GetGenericArguments().Length == 1) - .MakeGenericMethod(typeof(byte)); - - var byteParam = Expression.Parameter(typeof(byte), "b"); - var predicate = Expression.Lambda>( - Expression.Equal(byteParam, Expression.Constant((byte)0)), - byteParam); - var allZeroCheck = Expression.Call(trueForAllMethod, prm, predicate); - - var conditionCheck = Expression.OrElse( - Expression.OrElse(isNullCheck, isEmptyCheck), - allZeroCheck); - var nullConstant = Expression.Constant(null, typeof(string)); + var getStringCall = Expression.Call( Expression.Call( EncodingGetEncodingMethodInfo, Expression.Constant(encoding.CodePage)), - EncodingGetStringMethodInfo, prm); - - var conditionalExpression = Expression.Condition(conditionCheck, nullConstant, getStringCall); + EncodingGetStringMethodInfo, + prm); - var result = Expression.Lambda>(conditionalExpression, prm); - return result; + var conditionalExpression = Expression.Condition(isNullCheck, nullConstant, getStringCall); + return Expression.Lambda>(conditionalExpression, prm); } }