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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ v0.3.0 (Unreleased)

### Bug fixes
* Preserve `LowCardinality(...)` and `Nullable(...)` wrappers from `HasColumnType(...)` in generated migration DDL. Previously the wrapper was stripped during type-mapping resolution, so the migration emitted the inner type. ([#18](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/18))
* Preserve explicit `HasColumnType(...)` text whenever the resolved mapping's canonical store type differs from the user's input — fixes `Enum8(...)` and `AggregateFunction(...)` columns silently emitting `String` in generated DDL. Also covers `Enum16`, `SimpleAggregateFunction`, `Nested`, and the parameter-bearing forms (`Decimal128(S)`, `Json(...)` with type hints, etc.). ([#24](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/24))

v0.2.0
---
Expand Down
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ v0.3.0 (Unreleased)
The provider handles ClickHouse 1-based indexing for arrays automatically and supports deep nesting and explicit casting/`.GetValue<T>()`.
* **SimpleJSON functions**: support for `simpleJSONExtract*` and `simpleJSONHas` via `EF.Functions`.

### Bug fixes
* `HasColumnType("Enum8(...)")`, `HasColumnType("AggregateFunction(...)")`, and similar parameterized or aliased store types are now preserved verbatim in generated migration DDL. Previously these silently emitted `String` because the resolver canonicalized to a generic fallback mapping. ([#24](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/24))

v0.2.0
---
### Table engine and DDL
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Data;
using System.Linq;
using System.Net;
using System.Numerics;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -296,20 +297,19 @@ public ClickHouseTypeMappingSource(
?? FindExistingMapping(mappingInfo)
?? FindDecimalMapping(mappingInfo);

return mapping is null ? null : PreserveStoreTypeWrappers(mapping, mappingInfo);
return mapping is null ? null : PreserveExplicitStoreType(mapping, mappingInfo);
}

// ParseStoreTypeName strips LowCardinality(...) and Nullable(...) wrappers so the inner
// CLR mapping can be resolved. EF Core's Property.GetColumnType() prefers
// RelationalTypeMapping.StoreType over the user's annotation, so without re-wrapping
// here the wrapper would be lost from generated migrations DDL and SQL parameter types.
private static RelationalTypeMapping PreserveStoreTypeWrappers(
// ParseStoreTypeName may normalize or partially unwrap the store type in order to resolve
// the underlying CLR mapping. EF Core's Property.GetColumnType() prefers
// RelationalTypeMapping.StoreType over the user's annotation, so preserve the explicit
// HasColumnType(...) text verbatim on the resolved mapping.
private static RelationalTypeMapping PreserveExplicitStoreType(
RelationalTypeMapping mapping,
in RelationalTypeMappingInfo mappingInfo)
{
var storeTypeName = mappingInfo.StoreTypeName;
if (string.IsNullOrEmpty(storeTypeName)
|| !HasWrapper(storeTypeName)
|| string.Equals(storeTypeName, mapping.StoreType, StringComparison.Ordinal))
{
return mapping;
Expand All @@ -323,13 +323,6 @@ private static RelationalTypeMapping PreserveStoreTypeWrappers(
return mapping.Clone(in cloneInfo, storeTypePostfix: StoreTypePostfix.None);
}

private static bool HasWrapper(string storeTypeName)
{
var s = storeTypeName.AsSpan().TrimStart();
return s.StartsWith("LowCardinality(", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Nullable(", StringComparison.OrdinalIgnoreCase);
}

private static bool IsCollectionClrType(Type? clrType)
{
if (clrType is null || clrType == typeof(string) || clrType == typeof(byte[]))
Expand Down Expand Up @@ -777,25 +770,32 @@ private static bool TryUnwrapPrefix(string s, string prefix, out string inner)
return null;

var inner = storeTypeName[(openParen + 1)..closeParen];
var results = SplitTopLevel(inner);

if (expectedCount.HasValue && results.Count != expectedCount.Value)
return null;

return results;
}

private static List<string> SplitTopLevel(string input)
{
var results = new List<string>();
var depth = 0;
var start = 0;

for (var i = 0; i < inner.Length; i++)
for (var i = 0; i < input.Length; i++)
{
if (inner[i] == '(') depth++;
else if (inner[i] == ')') depth--;
else if (inner[i] == ',' && depth == 0)
if (input[i] == '(') depth++;
else if (input[i] == ')') depth--;
else if (input[i] == ',' && depth == 0)
{
results.Add(inner[start..i].Trim());
results.Add(input[start..i].Trim());
start = i + 1;
}
}

results.Add(inner[start..].Trim());

if (expectedCount.HasValue && results.Count != expectedCount.Value)
return null;
results.Add(input[start..].Trim());

return results;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ public override async Task Client_eval_Union_FirstOrDefault(bool async)
(await Assert.ThrowsAsync<InvalidOperationException>(
() => base.Client_eval_Union_FirstOrDefault(async))).Message);

// ClickHouse's query optimizer pushes the outer City projection into the UNION ALL subquery,
// causing the inner DISTINCT to operate on just City instead of all entity columns.
// Result: 10 distinct cities instead of 11 rows (two México D.F. entries merge).
// This is a ClickHouse column-pruning optimization, not a provider bug.
//
// Previously this override was Task.CompletedTask, which silently masked the wrong-result
// behavior. Assert the specific observed mismatch so (a) the regression is documented,
// (b) the test fails with a clear signal if the row counts change, and
// (c) CI catches an unexpected fix (e.g. ClickHouse optimizer update) so we can remove
// the workaround.
// ClickHouse's query optimizer historically pushed the outer City projection into the
// UNION ALL subquery, causing the inner DISTINCT to operate on just City instead of all
// entity columns (10 rows instead of 11; two México D.F. entries merge). Newer ClickHouse
// versions fix this and return the correct 11 rows. Accept either outcome until the older
// image is no longer in active use; assert the specific mismatch pattern when we do see
// the legacy behavior, so a different wrong answer still fails loudly.
public override async Task Concat_with_distinct_on_both_source_and_pruning(bool async)
{
var ex = await Assert.ThrowsAsync<Xunit.Sdk.EqualException>(
() => base.Concat_with_distinct_on_both_source_and_pruning(async));

Assert.Matches(@"Expected:\s*11\b", ex.Message);
Assert.Matches(@"Actual:\s*10\b", ex.Message);
try
{
await base.Concat_with_distinct_on_both_source_and_pruning(async);
}
catch (Xunit.Sdk.EqualException ex)
{
Assert.Matches(@"Expected:\s*11\b", ex.Message);
Assert.Matches(@"Actual:\s*10\b", ex.Message);
}
}
}
39 changes: 33 additions & 6 deletions test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,25 +1141,52 @@ public async Task Where_Dynamic_ById()
public class TypeMappingSourceStoreTypeTests
{
/// <summary>
/// Verifies that Nullable(...) and LowCardinality(...) wrappers in the user's
/// configured store type are preserved on the resolved mapping's StoreType.
/// The internal resolver unwraps these wrappers to find the inner CLR mapping,
/// then re-wraps so migration DDL and SQL parameter types see the full form.
/// Verifies that explicit HasColumnType(...) text is preserved on the resolved
/// mapping's StoreType, even when the resolver normalizes/parses to discover
/// CLR semantics.
/// </summary>
[Theory]
[InlineData("Nullable(Int32)")]
[InlineData("Nullable(String)")]
[InlineData("LowCardinality(String)")]
[InlineData("LowCardinality(Nullable(String))")]
[InlineData("Nullable(Float64)")]
public void FindMapping_PreservesWrapperTypes(string storeType)
[InlineData("Enum8('a'=1,'b'=2)")]
[InlineData("Dynamic(max_types=16)")]
[InlineData("Json(max_dynamic_paths=256, max_dynamic_types=8, a.b UInt32, SKIP a.e)")]
[InlineData("Decimal128(18)")]
public void FindMapping_PreservesExplicitStoreType(string storeType)
{
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(object), storeType);
Assert.NotNull(mapping);
Assert.Equal(storeType, mapping.StoreType);
}

[Theory]
[InlineData("AggregateFunction(uniq, UInt64)")]
[InlineData("SimpleAggregateFunction(sum, UInt64)")]
[InlineData("Nested(k String, v UInt64)")]
public void FindMapping_PreservesUnknownParameterizedStoreType(string storeType)
{
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(string), storeType);
Assert.NotNull(mapping);
Assert.Equal(storeType, mapping.StoreType);
}

[Fact]
public void FindMapping_ClrEnum_WithExplicitEnumStoreType_Preserved()
{
var source = GetTypeMappingSource();
var storeType = "Enum8('a'=1,'b'=2,'c'=3)";
var mapping = source.FindMapping(typeof(TestEnum8), storeType);
Assert.NotNull(mapping);
Assert.Equal(typeof(TestEnum8), mapping.ClrType);
Assert.Equal(storeType, mapping.StoreType);
Assert.NotNull(mapping.Converter);
}

[Fact]
public void FindMapping_PreservesNullableDecimalWrapper()
{
Expand Down Expand Up @@ -1364,7 +1391,7 @@ public void FindMapping_ClickHouseDecimal_WithStoreType()
var mapping = source.FindMapping(typeof(ClickHouseDecimal), "Decimal128(18)");
Assert.NotNull(mapping);
Assert.Equal(typeof(ClickHouseDecimal), mapping.ClrType);
Assert.Equal("Decimal(38,18)", mapping.StoreType);
Assert.Equal("Decimal128(18)", mapping.StoreType);
}

[Fact]
Expand Down
17 changes: 10 additions & 7 deletions test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,28 +379,31 @@ public void Ring_SqlLiteral_GeneratesArrayOfTupleSyntax()
}

[Fact]
public void FindMapping_Point_StoreType_ShowsComposedType()
public void FindMapping_Point_StoreType_PreservesAlias()
{
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(object), "Point")!;
// Store type shows the composed structural type, not the alias
Assert.Equal("Tuple(Float64, Float64)", mapping.StoreType);
// The driver registers Point as a first-class plain type (PointType :
// TupleType), so the alias round-trips through parameter binding and DDL.
// Preserve the user's HasColumnType text rather than expanding it to the
// structural Tuple(Float64, Float64) form.
Assert.Equal("Point", mapping.StoreType);
}

[Fact]
public void FindMapping_Ring_StoreType_ShowsComposedType()
public void FindMapping_Ring_StoreType_PreservesAlias()
{
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(object), "Ring")!;
Assert.Equal("Array(Tuple(Float64, Float64))", mapping.StoreType);
Assert.Equal("Ring", mapping.StoreType);
}

[Fact]
public void FindMapping_Geometry_StoreType_ShowsVariantComposition()
public void FindMapping_Geometry_StoreType_PreservesAlias()
{
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(object), "Geometry")!;
Assert.Contains("Variant(", mapping.StoreType);
Assert.Equal("Geometry", mapping.StoreType);
}

private static Microsoft.EntityFrameworkCore.Storage.IRelationalTypeMappingSource GetTypeMappingSource()
Expand Down
11 changes: 8 additions & 3 deletions test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,12 +353,17 @@ public void FindMapping_Object_WithJsonStoreType_Resolves()
}

[Fact]
public void FindMapping_JsonWithTypeHints_Resolves()
public void FindMapping_JsonWithTypeHints_PreservesHints()
{
// Explicit HasColumnType text flows through verbatim so DDL and the SQL
// parameter type both see the hints. The driver's JsonType.Parse accepts
// the hint syntax in parameter type strings; runtime materialization is
// unchanged because the mapping is still a JsonNode-typed mapping.
var source = GetTypeMappingSource();
var mapping = source.FindMapping(typeof(object), "Json(name String, age Int32)");
var storeType = "Json(name String, age Int32)";
var mapping = source.FindMapping(typeof(object), storeType);
Assert.NotNull(mapping);
Assert.Equal("Json", mapping.StoreType);
Assert.Equal(storeType, mapping.StoreType);
}

[Fact]
Expand Down
9 changes: 9 additions & 0 deletions test/EFCore.ClickHouse.Tests/MigrationSqlGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,10 @@ public void HasColumnType_Nullable_String_preserved_in_CreateTable_DDL()
public void HasColumnType_Array_LowCardinality_element_preserved_in_CreateTable_DDL()
=> AssertColumnTypePreserved<ArrayLowCardinalityContext>("Array(LowCardinality(String))");

[Fact]
public void HasColumnType_AggregateFunction_preserved_in_CreateTable_DDL()
=> AssertColumnTypePreserved<AggregateFunctionContext>("AggregateFunction(uniq, UInt64)");

// Nullable CLR property + LowCardinality(...) store type must not auto-wrap to
// Nullable(LowCardinality(...)) — ClickHouse rejects that wrapper order. The user
// is responsible for writing LowCardinality(Nullable(...)) when they want both.
Expand Down Expand Up @@ -1008,6 +1012,11 @@ private sealed class NullableStringContext : LowCardinalityContextBase
protected override string ColumnType => "Nullable(String)";
}

private sealed class AggregateFunctionContext : LowCardinalityContextBase
{
protected override string ColumnType => "AggregateFunction(uniq, UInt64)";
}

private sealed class NullablePropertyLowCardinalityContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
Expand Down
Loading