diff --git a/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs index 1863b76..431e14c 100644 --- a/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs +++ b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs @@ -1713,6 +1713,39 @@ public void FindMapping_PreservesUnknownParameterizedStoreType(string storeType) Assert.Equal(storeType, mapping.StoreType); } + // Matrix coverage for store types whose canonical mapping diverges from the + // user's HasColumnType text. Each case below was either silently lossy before + // PR #25 (aliased to a generic fallback) or canonicalized to a different + // string by the underlying mapping. The PreserveExplicitStoreType pathway + // should restore the user's verbatim text in every case. + [Theory] + // Aliased scalars — mapping.StoreType used to come from the aliased target, + // not the alias key. E.g. BFloat16 → Float32Mapping (StoreType "Float32"), + // Date32 → DateOnlyMapping (StoreType "Date"). + [InlineData(typeof(float), "BFloat16")] + [InlineData(typeof(DateOnly), "Date32")] + // Enum16 is the Enum8 sibling — same StringMapping fallback, same fix. + [InlineData(typeof(string), "Enum16('x'=100,'y'=200)")] + // Decimal32/64/256 canonicalize to "Decimal(P,S)" via ClickHouseDecimalTypeMapping. + [InlineData(typeof(decimal), "Decimal32(4)")] + [InlineData(typeof(decimal), "Decimal64(8)")] + [InlineData(typeof(ClickHouseDecimal), "Decimal256(38)")] + [InlineData(typeof(decimal), "Decimal(18, 4)")] + // Time64(N) — verify the parameter survives the parse round-trip. + [InlineData(typeof(TimeSpan), "Time64(6)")] + // FixedString(N) — parameter-bearing scalar. + [InlineData(typeof(string), "FixedString(16)")] + // DateTime / DateTime64 with timezones — the user's timezone string must survive. + [InlineData(typeof(DateTime), "DateTime('UTC')")] + [InlineData(typeof(DateTime), "DateTime64(6, 'Europe/Berlin')")] + public void FindMapping_CanonicallyDivergentStoreType_PreservesVerbatim(Type clrType, string storeType) + { + var source = GetTypeMappingSource(); + var mapping = source.FindMapping(clrType, storeType); + Assert.NotNull(mapping); + Assert.Equal(storeType, mapping.StoreType); + } + [Fact] public void FindMapping_ClrEnum_WithExplicitEnumStoreType_Preserved() { diff --git a/test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs index 4c3f2a5..8ff834c 100644 --- a/test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs +++ b/test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs @@ -31,6 +31,18 @@ public class MultiPolygonEntity public Tuple[][][] Val { get; set; } = []; } +public class LineStringEntity +{ + public long Id { get; set; } + public Tuple[] Val { get; set; } = []; +} + +public class MultiLineStringEntity +{ + public long Id { get; set; } + public Tuple[][] Val { get; set; } = []; +} + public class GeometryEntity { public long Id { get; set; } @@ -131,6 +143,42 @@ protected override void OnModelCreating(ModelBuilder m) } } +public class LineStringDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public LineStringDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("geo_linestring_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Val).HasColumnName("val").HasColumnType("LineString"); + }); + } +} + +public class MultiLineStringDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public MultiLineStringDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("geo_multilinestring_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Val).HasColumnName("val").HasColumnType("MultiLineString"); + }); + } +} + #endregion #region Fixture @@ -234,6 +282,30 @@ INSERT INTO geo_multipolygon_test VALUES await cmd.ExecuteNonQueryAsync(); } + // LineString table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE geo_linestring_test ( + id Int64, + val LineString + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + + // MultiLineString table + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE geo_multilinestring_test ( + id Int64, + val MultiLineString + ) ENGINE = MergeTree() ORDER BY id + """; + await cmd.ExecuteNonQueryAsync(); + } + // Geometry table (variant of geo types) // Ring and LineString share the same underlying type, which ClickHouse flags as suspicious using (var cmd = connection.CreateCommand()) @@ -577,6 +649,102 @@ public async Task ReadAll_MultiPolygons_RoundTrip() Assert.Single(rows[0].Val[0]); Assert.Equal(5, rows[0].Val[0][0].Length); } + + [Fact] + public async Task Insert_MultiPolygon_RoundTrip() + { + // Exercises the write path with {p:MultiPolygon} as the parameter type — + // confirms the driver resolves the alias to ArrayType>>. + await using var ctx = new MultiPolygonDbContext(_fixture.ConnectionString); + var multipoly = new[] + { + new[] + { + new[] + { + Tuple.Create(0.0, 0.0), + Tuple.Create(2.0, 0.0), + Tuple.Create(2.0, 2.0), + Tuple.Create(0.0, 0.0), + } + }, + new[] + { + new[] + { + Tuple.Create(10.0, 10.0), + Tuple.Create(12.0, 10.0), + Tuple.Create(12.0, 12.0), + Tuple.Create(10.0, 10.0), + } + }, + }; + ctx.Entities.Add(new MultiPolygonEntity { Id = 100, Val = multipoly }); + await ctx.SaveChangesAsync(); + + await using var ctx2 = new MultiPolygonDbContext(_fixture.ConnectionString); + var row = await ctx2.Entities.Where(e => e.Id == 100).AsNoTracking().SingleAsync(); + Assert.Equal(2, row.Val.Length); + Assert.Equal(2.0, row.Val[0][0][1].Item1); + Assert.Equal(12.0, row.Val[1][0][2].Item1); + } +} + +[Collection("GeoTypes")] +public class GeoLineStringTests +{ + private readonly GeoTypesFixture _fixture; + public GeoLineStringTests(GeoTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Insert_LineString_RoundTrip() + { + // {p:LineString} resolves to ArrayType in the driver — + // structurally identical to Ring but a distinct alias. + await using var ctx = new LineStringDbContext(_fixture.ConnectionString); + var line = new[] + { + Tuple.Create(0.0, 0.0), + Tuple.Create(1.0, 2.0), + Tuple.Create(3.0, 4.0), + }; + ctx.Entities.Add(new LineStringEntity { Id = 100, Val = line }); + await ctx.SaveChangesAsync(); + + await using var ctx2 = new LineStringDbContext(_fixture.ConnectionString); + var row = await ctx2.Entities.Where(e => e.Id == 100).AsNoTracking().SingleAsync(); + Assert.Equal(3, row.Val.Length); + Assert.Equal(3.0, row.Val[2].Item1); + Assert.Equal(4.0, row.Val[2].Item2); + } +} + +[Collection("GeoTypes")] +public class GeoMultiLineStringTests +{ + private readonly GeoTypesFixture _fixture; + public GeoMultiLineStringTests(GeoTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Insert_MultiLineString_RoundTrip() + { + // {p:MultiLineString} resolves to ArrayType>. + await using var ctx = new MultiLineStringDbContext(_fixture.ConnectionString); + var lines = new[] + { + new[] { Tuple.Create(0.0, 0.0), Tuple.Create(1.0, 1.0) }, + new[] { Tuple.Create(5.0, 5.0), Tuple.Create(6.0, 6.0), Tuple.Create(7.0, 7.0) }, + }; + ctx.Entities.Add(new MultiLineStringEntity { Id = 100, Val = lines }); + await ctx.SaveChangesAsync(); + + await using var ctx2 = new MultiLineStringDbContext(_fixture.ConnectionString); + var row = await ctx2.Entities.Where(e => e.Id == 100).AsNoTracking().SingleAsync(); + Assert.Equal(2, row.Val.Length); + Assert.Equal(2, row.Val[0].Length); + Assert.Equal(3, row.Val[1].Length); + Assert.Equal(7.0, row.Val[1][2].Item1); + } } [Collection("GeoTypes")] diff --git a/test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs index f9562ed..e5eda3c 100644 --- a/test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs +++ b/test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs @@ -20,6 +20,12 @@ public class JsonStringEntity public string? Data { get; set; } } +public class JsonHintedEntity +{ + public long Id { get; set; } + public JsonNode? Data { get; set; } +} + #endregion #region DbContexts @@ -60,6 +66,27 @@ protected override void OnModelCreating(ModelBuilder m) } } +public class JsonHintedDbContext : DbContext +{ + public DbSet Entities => Set(); + private readonly string _connectionString; + public JsonHintedDbContext(string cs) => _connectionString = cs; + protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseClickHouse(_connectionString); + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => + { + e.ToTable("json_hinted_test"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + // The PR-25 behavior change: HasColumnType text flows through verbatim + // to both DDL and to the SQL parameter type ({p:Json(name String, age Int32)}). + e.Property(x => x.Data).HasColumnName("data") + .HasColumnType("Json(name String, age Int32)"); + }); + } +} + #endregion #region Fixture @@ -122,6 +149,20 @@ data Json """; await cmd.ExecuteNonQueryAsync(); } + + // Table for Json-with-type-hints round-trip. The hint syntax exists + // so the server can pre-allocate typed sub-columns for known paths. + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE json_hinted_test ( + id Int64, + data Json(name String, age Int32) + ) ENGINE = MergeTree() ORDER BY id + SETTINGS allow_experimental_json_type = 1 + """; + await cmd.ExecuteNonQueryAsync(); + } } public Task DisposeAsync() => Task.CompletedTask; @@ -256,6 +297,65 @@ public async Task Insert_NullJson_RoundTrip() } } +[Collection("JsonTypes")] +public class JsonHintedRoundTripTests +{ + private readonly JsonTypesFixture _fixture; + public JsonHintedRoundTripTests(JsonTypesFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Insert_JsonWithTypeHints_RoundTrip() + { + // Validates that PR #25's "preserve verbatim HasColumnType" behavior is safe + // end-to-end for parameterized Json columns: the SQL parameter type becomes + // {p:Json(name String, age Int32)}, which the driver parses via JsonType.Parse. + await using var ctx = new JsonHintedDbContext(_fixture.ConnectionString); + + var entity = new JsonHintedEntity + { + Id = 200, + Data = JsonNode.Parse("""{"name":"Alice","age":30,"extra":"untyped"}""") + }; + + ctx.Entities.Add(entity); + await ctx.SaveChangesAsync(); + + await using var readCtx = new JsonHintedDbContext(_fixture.ConnectionString); + var result = await readCtx.Entities + .Where(e => e.Id == 200) + .AsNoTracking().SingleAsync(); + + Assert.NotNull(result.Data); + Assert.Equal("Alice", result.Data!["name"]?.GetValue()); + // Hinted Int32 path materializes as Int32; un-hinted paths still flow as Int64 + // through the dynamic JSON sub-columns, but JsonNode coerces either way. + Assert.Equal(30, result.Data!["age"]?.GetValue()); + Assert.Equal("untyped", result.Data!["extra"]?.GetValue()); + } + + [Fact] + public async Task CreateTable_JsonWithTypeHints_PreservesDDL() + { + // Independent confirmation that the server-side column type matches the + // user's HasColumnType text once the row has been written through EF Core. + await using var ctx = new JsonHintedDbContext(_fixture.ConnectionString); + ctx.Entities.Add(new JsonHintedEntity { Id = 201, Data = JsonNode.Parse("""{"name":"x"}""") }); + await ctx.SaveChangesAsync(); + + using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT type FROM system.columns WHERE table = 'json_hinted_test' AND name = 'data'"; + var actualType = (string?)await cmd.ExecuteScalarAsync(); + Assert.NotNull(actualType); + // ClickHouse uppercases the base name and reorders hint fields alphabetically + // when reporting in system.columns. The hints themselves survive. + Assert.Contains("JSON", actualType, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name String", actualType); + Assert.Contains("age Int32", actualType); + } +} + public class JsonInsertEntity { public long Id { get; set; } diff --git a/test/EFCore.ClickHouse.Tests/MigrationSqlGeneratorTests.cs b/test/EFCore.ClickHouse.Tests/MigrationSqlGeneratorTests.cs index e991f28..1f69333 100644 --- a/test/EFCore.ClickHouse.Tests/MigrationSqlGeneratorTests.cs +++ b/test/EFCore.ClickHouse.Tests/MigrationSqlGeneratorTests.cs @@ -927,6 +927,25 @@ public void HasColumnType_Array_LowCardinality_element_preserved_in_CreateTable_ public void HasColumnType_AggregateFunction_preserved_in_CreateTable_DDL() => AssertColumnTypePreserved("AggregateFunction(uniq, UInt64)"); + // Matrix coverage: any store type whose canonical mapping diverges from the + // user's HasColumnType text. Each context below would have silently emitted + // the resolver's canonical form (typically "String") in DDL before PR #25. + [Fact] + public void HasColumnType_Enum16_with_values_preserved_in_CreateTable_DDL() + => AssertColumnTypePreserved("Enum16('x'=100,'y'=200)"); + + [Fact] + public void HasColumnType_FixedString_preserved_in_CreateTable_DDL() + => AssertColumnTypePreserved("FixedString(16)"); + + [Fact] + public void HasColumnType_SimpleAggregateFunction_preserved_in_CreateTable_DDL() + => AssertColumnTypePreserved("SimpleAggregateFunction(sum, UInt64)"); + + [Fact] + public void HasColumnType_Nested_preserved_in_CreateTable_DDL() + => AssertColumnTypePreserved("Nested(k String, v 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. @@ -1017,6 +1036,26 @@ private sealed class AggregateFunctionContext : LowCardinalityContextBase protected override string ColumnType => "AggregateFunction(uniq, UInt64)"; } + private sealed class Enum16Context : LowCardinalityContextBase + { + protected override string ColumnType => "Enum16('x'=100,'y'=200)"; + } + + private sealed class FixedStringContext : LowCardinalityContextBase + { + protected override string ColumnType => "FixedString(16)"; + } + + private sealed class SimpleAggregateFunctionContext : LowCardinalityContextBase + { + protected override string ColumnType => "SimpleAggregateFunction(sum, UInt64)"; + } + + private sealed class NestedContext : LowCardinalityContextBase + { + protected override string ColumnType => "Nested(k String, v UInt64)"; + } + private sealed class NullablePropertyLowCardinalityContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)