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
33 changes: 33 additions & 0 deletions test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
168 changes: 168 additions & 0 deletions test/EFCore.ClickHouse.Tests/GeoTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ public class MultiPolygonEntity
public Tuple<double, double>[][][] Val { get; set; } = [];
}

public class LineStringEntity
{
public long Id { get; set; }
public Tuple<double, double>[] Val { get; set; } = [];
}

public class MultiLineStringEntity
{
public long Id { get; set; }
public Tuple<double, double>[][] Val { get; set; } = [];
}

public class GeometryEntity
{
public long Id { get; set; }
Expand Down Expand Up @@ -131,6 +143,42 @@ protected override void OnModelCreating(ModelBuilder m)
}
}

public class LineStringDbContext : DbContext
{
public DbSet<LineStringEntity> Entities => Set<LineStringEntity>();
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<LineStringEntity>(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<MultiLineStringEntity> Entities => Set<MultiLineStringEntity>();
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<MultiLineStringEntity>(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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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<ArrayType<ArrayType<PointType>>>.
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<PointType> 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<ArrayType<PointType>>.
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")]
Expand Down
100 changes: 100 additions & 0 deletions test/EFCore.ClickHouse.Tests/JsonTypeMappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +66,27 @@ protected override void OnModelCreating(ModelBuilder m)
}
}

public class JsonHintedDbContext : DbContext
{
public DbSet<JsonHintedEntity> Entities => Set<JsonHintedEntity>();
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<JsonHintedEntity>(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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>());
// 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<int>());
Assert.Equal("untyped", result.Data!["extra"]?.GetValue<string>());
}

[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; }
Expand Down
Loading
Loading