diff --git a/README.md b/README.md index df7f81b..c790ae0 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,51 @@ This provider is in early development. It supports **read-only queries** — you `Math.Abs`, `Floor`, `Ceiling`, `Round`, `Truncate`, `Pow`, `Sqrt`, `Cbrt`, `Exp`, `Log`, `Log2`, `Log10`, `Sign`, `Sin`, `Cos`, `Tan`, `Asin`, `Acos`, `Atan`, `Atan2`, `RadiansToDegrees`, `DegreesToRadians`, `IsNaN`, `IsInfinity`, `IsFinite`, `IsPositiveInfinity`, `IsNegativeInfinity` — with both `Math` and `MathF` overloads. +### INSERT via SaveChanges + +`SaveChanges` supports INSERT operations using the driver's native `InsertBinaryAsync` API — RowBinary encoding with GZip compression, far more efficient than parameterized SQL. + +```csharp +await using var ctx = new AnalyticsContext(); + +ctx.PageViews.Add(new PageView +{ + Id = 1, + Path = "/home", + Date = new DateOnly(2024, 6, 15), + UserAgent = "Mozilla/5.0" +}); + +await ctx.SaveChangesAsync(); +``` + +Entities transition from `Added` to `Unchanged` after save, just like any other EF Core provider. + +**Batch size** is configurable (default 1000) — controls how many entities are accumulated before flushing to ClickHouse: + +```csharp +optionsBuilder.UseClickHouse("Host=localhost", o => o.MaxBatchSize(5000)); +``` + +### Bulk Insert + +For high-throughput loads that don't need change tracking, use `BulkInsertAsync`: + +```csharp +var events = Enumerable.Range(0, 100_000) + .Select(i => new PageView { Id = i, Path = $"/page/{i}", Date = DateOnly.FromDateTime(DateTime.Today) }); + +long rowsInserted = await ctx.BulkInsertAsync(events); +``` + +This calls `InsertBinaryAsync` directly, bypassing EF Core's change tracker entirely. Entities are **not** tracked after insert. + ### Not Yet Implemented -- INSERT / UPDATE / DELETE (modification commands are stubbed) +- UPDATE / DELETE (ClickHouse mutations are async, not OLTP-compatible) - Migrations - JOINs, subqueries, set operations - Advanced types: Array, Tuple, Nullable(T), LowCardinality, Nested, TimeSpan/TimeOnly -- Batched inserts ## Building diff --git a/src/EFCore.ClickHouse/Extensions/ClickHouseBulkInsertExtensions.cs b/src/EFCore.ClickHouse/Extensions/ClickHouseBulkInsertExtensions.cs new file mode 100644 index 0000000..f3456de --- /dev/null +++ b/src/EFCore.ClickHouse/Extensions/ClickHouseBulkInsertExtensions.cs @@ -0,0 +1,58 @@ +using ClickHouse.EntityFrameworkCore.Storage.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace ClickHouse.EntityFrameworkCore.Extensions; + +public static class ClickHouseBulkInsertExtensions +{ + /// + /// Inserts entities into ClickHouse using the driver's native binary insert protocol. + /// This bypasses EF Core change tracking entirely and is intended for high-throughput bulk loads. + /// Entities are NOT tracked or marked as Unchanged after insert. + /// + public static async Task BulkInsertAsync( + this DbContext context, + IEnumerable entities, + CancellationToken cancellationToken = default) where TEntity : class + { + var connection = context.GetService(); + var client = connection.GetClickHouseClient(); + + var entityType = context.Model.FindEntityType(typeof(TEntity)) + ?? throw new InvalidOperationException( + $"The entity type '{typeof(TEntity).Name}' is not part of the model for the current context."); + + var tableName = entityType.GetTableName() + ?? throw new InvalidOperationException( + $"The entity type '{typeof(TEntity).Name}' is not mapped to a table."); + + // Build column list and property accessors + var properties = entityType.GetProperties() + .Where(p => p.GetTableColumnMappings().Any()) + .ToList(); + + var columns = properties + .Select(p => p.GetTableColumnMappings().First().Column.Name) + .ToList(); + + var accessors = properties + .Select(p => p.GetGetter()) + .ToList(); + + // Convert entities to row arrays + // TODO quite inefficient, update this after adding direct POCO insert to client API + var rows = entities.Select(entity => + { + var row = new object[accessors.Count]; + for (var i = 0; i < accessors.Count; i++) + { + row[i] = accessors[i].GetClrValue(entity) ?? DBNull.Value; + } + return row; + }); + + return await client.InsertBinaryAsync(tableName, columns, rows, cancellationToken: cancellationToken); + } +} diff --git a/src/EFCore.ClickHouse/Storage/Internal/ClickHouseRelationalConnection.cs b/src/EFCore.ClickHouse/Storage/Internal/ClickHouseRelationalConnection.cs index e830d6f..6016a92 100644 --- a/src/EFCore.ClickHouse/Storage/Internal/ClickHouseRelationalConnection.cs +++ b/src/EFCore.ClickHouse/Storage/Internal/ClickHouseRelationalConnection.cs @@ -1,5 +1,6 @@ using System.Data; using System.Data.Common; +using ClickHouse.Driver; using ClickHouse.Driver.ADO; using ClickHouse.EntityFrameworkCore.Infrastructure.Internal; using Microsoft.EntityFrameworkCore; @@ -69,4 +70,14 @@ public override Task BeginTransactionAsync( IsolationLevel isolationLevel, CancellationToken cancellationToken = default) => Task.FromResult(new ClickHouseTransaction()); + + public IClickHouseClient GetClickHouseClient() + { + if (_dataSource is ClickHouseDataSource clickHouseDataSource) + return clickHouseDataSource.GetClient(); + + throw new InvalidOperationException( + "Cannot obtain IClickHouseClient. The connection must be configured with a connection string " + + "or ClickHouseDataSource, not a raw DbConnection."); + } } diff --git a/src/EFCore.ClickHouse/Storage/Internal/IClickHouseRelationalConnection.cs b/src/EFCore.ClickHouse/Storage/Internal/IClickHouseRelationalConnection.cs index 05e7b11..60661bc 100644 --- a/src/EFCore.ClickHouse/Storage/Internal/IClickHouseRelationalConnection.cs +++ b/src/EFCore.ClickHouse/Storage/Internal/IClickHouseRelationalConnection.cs @@ -1,3 +1,4 @@ +using ClickHouse.Driver; using Microsoft.EntityFrameworkCore.Storage; namespace ClickHouse.EntityFrameworkCore.Storage.Internal; @@ -5,4 +6,5 @@ namespace ClickHouse.EntityFrameworkCore.Storage.Internal; public interface IClickHouseRelationalConnection : IRelationalConnection { IClickHouseRelationalConnection CreateMasterConnection(); + IClickHouseClient GetClickHouseClient(); } diff --git a/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatch.cs b/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatch.cs new file mode 100644 index 0000000..870b77b --- /dev/null +++ b/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatch.cs @@ -0,0 +1,112 @@ +using ClickHouse.EntityFrameworkCore.Storage.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Update; + +namespace ClickHouse.EntityFrameworkCore.Update.Internal; + +public class ClickHouseModificationCommandBatch : ModificationCommandBatch +{ + private readonly List _commands = []; + private readonly int _maxBatchSize; + private bool _completed; + private bool _moreExpected; + + public ClickHouseModificationCommandBatch(int maxBatchSize) + { + _maxBatchSize = maxBatchSize; + } + + public override IReadOnlyList ModificationCommands => _commands; + + public override bool RequiresTransaction => false; + + public override bool AreMoreBatchesExpected => _moreExpected; + + public override bool TryAddCommand(IReadOnlyModificationCommand modificationCommand) + { + if (_completed) + throw new InvalidOperationException("Batch has already been completed."); + + if (modificationCommand.EntityState is EntityState.Modified) + throw new NotSupportedException( + "UPDATE operations are not supported by the ClickHouse EF Core provider. " + + "ClickHouse mutations (ALTER TABLE ... UPDATE) are asynchronous and not OLTP-compatible."); + + if (modificationCommand.EntityState is EntityState.Deleted) + throw new NotSupportedException( + "DELETE operations are not supported by the ClickHouse EF Core provider. " + + "ClickHouse mutations (ALTER TABLE ... DELETE) are asynchronous and not OLTP-compatible."); + + if (modificationCommand.EntityState is not EntityState.Added) + throw new NotSupportedException( + $"Unexpected entity state '{modificationCommand.EntityState}'. " + + "The ClickHouse EF Core provider only supports INSERT (EntityState.Added)."); + + // Block server-generated values (ClickHouse has no RETURNING / auto-increment) + foreach (var columnMod in modificationCommand.ColumnModifications) + { + if (columnMod.IsRead) + throw new NotSupportedException( + $"Server-generated values are not supported by the ClickHouse EF Core provider. " + + $"Column '{columnMod.ColumnName}' on table '{modificationCommand.TableName}' is configured " + + $"to read a value back from the database after INSERT. Remove ValueGeneratedOnAdd() or " + + $"use HasValueGenerator() with a client-side generator instead."); + } + + if (_commands.Count >= _maxBatchSize) + return false; + + _commands.Add(modificationCommand); + return true; + } + + public override void Complete(bool moreBatchesExpected) + { + _completed = true; + _moreExpected = moreBatchesExpected; + } + + public override void Execute(IRelationalConnection connection) + => ExecuteAsync(connection).GetAwaiter().GetResult(); + + public override async Task ExecuteAsync( + IRelationalConnection connection, + CancellationToken cancellationToken = default) + { + if (_commands.Count == 0) + return; + + var clickHouseConnection = (IClickHouseRelationalConnection)connection; + var client = clickHouseConnection.GetClickHouseClient(); + + // Group commands by table name and write-column set for correct row alignment + var groups = _commands.GroupBy(c => ( + c.TableName, + Columns: string.Join(",", c.ColumnModifications.Where(cm => cm.IsWrite).Select(cm => cm.ColumnName)))); + + foreach (var group in groups) + { + var tableName = group.Key.TableName; + var commands = group.ToList(); + + var columns = commands[0].ColumnModifications + .Where(cm => cm.IsWrite) + .Select(cm => cm.ColumnName) + .ToList(); + + var rows = commands.Select(cmd => + { + var writeColumns = cmd.ColumnModifications.Where(cm => cm.IsWrite).ToList(); + var row = new object[writeColumns.Count]; + for (var i = 0; i < writeColumns.Count; i++) + { + row[i] = writeColumns[i].Value ?? DBNull.Value; + } + return row; + }); + + await client.InsertBinaryAsync(tableName, columns, rows, cancellationToken: cancellationToken); + } + } +} diff --git a/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatchFactory.cs b/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatchFactory.cs index 81241db..40c55a0 100644 --- a/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatchFactory.cs +++ b/src/EFCore.ClickHouse/Update/Internal/ClickHouseModificationCommandBatchFactory.cs @@ -1,15 +1,22 @@ +using ClickHouse.EntityFrameworkCore.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Update; namespace ClickHouse.EntityFrameworkCore.Update.Internal; public class ClickHouseModificationCommandBatchFactory : IModificationCommandBatchFactory { - public ClickHouseModificationCommandBatchFactory(ModificationCommandBatchFactoryDependencies dependencies) + private const int DefaultMaxBatchSize = 1000; + private readonly int _maxBatchSize; + + public ClickHouseModificationCommandBatchFactory( + ModificationCommandBatchFactoryDependencies dependencies) { + _maxBatchSize = dependencies.CurrentContext.Context.GetService() + .Extensions.OfType() + .FirstOrDefault()?.MaxBatchSize ?? DefaultMaxBatchSize; } public ModificationCommandBatch Create() - => throw new NotSupportedException( - "SaveChanges write operations are not supported by ClickHouse.EntityFrameworkCore yet. " + - "This provider currently supports read-only query scenarios."); + => new ClickHouseModificationCommandBatch(_maxBatchSize); } diff --git a/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs b/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs new file mode 100644 index 0000000..994dc7d --- /dev/null +++ b/test/EFCore.ClickHouse.Tests/InsertIntegrationTests.cs @@ -0,0 +1,511 @@ +using ClickHouse.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore; +using Testcontainers.ClickHouse; +using Xunit; + +namespace EFCore.ClickHouse.Tests; + +#region Entities and DbContext + +public class InsertEntity +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Value { get; set; } +} + +public class InsertAllTypesEntity +{ + public long Id { get; set; } + public sbyte ValInt8 { get; set; } + public byte ValUInt8 { get; set; } + public short ValInt16 { get; set; } + public ushort ValUInt16 { get; set; } + public int ValInt32 { get; set; } + public uint ValUInt32 { get; set; } + public long ValInt64 { get; set; } + public ulong ValUInt64 { get; set; } + public float ValFloat32 { get; set; } + public double ValFloat64 { get; set; } + public decimal ValDecimal { get; set; } + public bool ValBool { get; set; } + public string ValString { get; set; } = string.Empty; + public Guid ValUuid { get; set; } + public DateOnly ValDate { get; set; } + public DateTime ValDatetime { get; set; } +} + +public class NullableInsertEntity +{ + public long Id { get; set; } + public string? NullableString { get; set; } + public int? NullableInt { get; set; } + public double? NullableDouble { get; set; } + public bool? NullableBool { get; set; } + public DateTime? NullableDatetime { get; set; } +} + +public class ServerGenEntity +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class ServerGenDbContext : DbContext +{ + private readonly string _connectionString; + + public ServerGenDbContext(string connectionString) + { + _connectionString = connectionString; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseClickHouse(_connectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("insert_entities"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id").ValueGeneratedOnAdd(); + entity.Property(e => e.Name).HasColumnName("name"); + }); + } +} + +public class InsertDbContext : DbContext +{ + public DbSet InsertEntities => Set(); + public DbSet AllTypes => Set(); + public DbSet NullableEntities => Set(); + + private readonly string _connectionString; + + public InsertDbContext(string connectionString) + { + _connectionString = connectionString; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseClickHouse(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("insert_entities"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + entity.Property(e => e.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("insert_all_types"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.ValInt8).HasColumnName("val_int8"); + entity.Property(e => e.ValUInt8).HasColumnName("val_uint8"); + entity.Property(e => e.ValInt16).HasColumnName("val_int16"); + entity.Property(e => e.ValUInt16).HasColumnName("val_uint16"); + entity.Property(e => e.ValInt32).HasColumnName("val_int32"); + entity.Property(e => e.ValUInt32).HasColumnName("val_uint32"); + entity.Property(e => e.ValInt64).HasColumnName("val_int64"); + entity.Property(e => e.ValUInt64).HasColumnName("val_uint64"); + entity.Property(e => e.ValFloat32).HasColumnName("val_float32"); + entity.Property(e => e.ValFloat64).HasColumnName("val_float64"); + entity.Property(e => e.ValDecimal).HasColumnName("val_decimal").HasColumnType("Decimal(18, 4)"); + entity.Property(e => e.ValBool).HasColumnName("val_bool"); + entity.Property(e => e.ValString).HasColumnName("val_string"); + entity.Property(e => e.ValUuid).HasColumnName("val_uuid"); + entity.Property(e => e.ValDate).HasColumnName("val_date"); + entity.Property(e => e.ValDatetime).HasColumnName("val_datetime"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("nullable_insert_entities"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.NullableString).HasColumnName("nullable_string"); + entity.Property(e => e.NullableInt).HasColumnName("nullable_int"); + entity.Property(e => e.NullableDouble).HasColumnName("nullable_double"); + entity.Property(e => e.NullableBool).HasColumnName("nullable_bool"); + entity.Property(e => e.NullableDatetime).HasColumnName("nullable_datetime"); + }); + } +} + +#endregion + +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(); + + using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString); + await connection.OpenAsync(); + + using var cmd1 = connection.CreateCommand(); + cmd1.CommandText = """ + CREATE TABLE insert_entities ( + id Int64, + name String, + value Int32 + ) ENGINE = MergeTree() + ORDER BY id + """; + await cmd1.ExecuteNonQueryAsync(); + + using var cmd2 = connection.CreateCommand(); + cmd2.CommandText = """ + CREATE TABLE insert_all_types ( + id Int64, + val_int8 Int8, + val_uint8 UInt8, + val_int16 Int16, + val_uint16 UInt16, + val_int32 Int32, + val_uint32 UInt32, + val_int64 Int64, + val_uint64 UInt64, + val_float32 Float32, + val_float64 Float64, + val_decimal Decimal(18, 4), + val_bool Bool, + val_string String, + val_uuid UUID, + val_date Date, + val_datetime DateTime + ) ENGINE = MergeTree() + ORDER BY id + """; + await cmd2.ExecuteNonQueryAsync(); + + using var cmd3 = connection.CreateCommand(); + cmd3.CommandText = """ + CREATE TABLE nullable_insert_entities ( + id Int64, + nullable_string Nullable(String), + nullable_int Nullable(Int32), + nullable_double Nullable(Float64), + nullable_bool Nullable(Bool), + nullable_datetime Nullable(DateTime) + ) ENGINE = MergeTree() + ORDER BY id + """; + await cmd3.ExecuteNonQueryAsync(); + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } +} + +public class InsertIntegrationTests : IClassFixture +{ + private readonly InsertFixture _fixture; + + public InsertIntegrationTests(InsertFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task SaveChanges_InsertSingleEntity() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + context.InsertEntities.Add(new InsertEntity { Id = 100, Name = "single", Value = 42 }); + await context.SaveChangesAsync(); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var result = await readContext.InsertEntities + .AsNoTracking() + .SingleOrDefaultAsync(e => e.Id == 100); + + Assert.NotNull(result); + Assert.Equal("single", result.Name); + Assert.Equal(42, result.Value); + } + + [Fact] + public async Task SaveChanges_InsertMultipleEntities() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + for (var i = 200; i < 205; i++) + { + context.InsertEntities.Add(new InsertEntity { Id = i, Name = $"multi_{i}", Value = i * 10 }); + } + + await context.SaveChangesAsync(); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var results = await readContext.InsertEntities + .Where(e => e.Id >= 200 && e.Id < 205) + .OrderBy(e => e.Id) + .AsNoTracking() + .ToListAsync(); + + Assert.Equal(5, results.Count); + for (var i = 0; i < 5; i++) + { + Assert.Equal(200 + i, results[i].Id); + Assert.Equal($"multi_{200 + i}", results[i].Name); + Assert.Equal((200 + i) * 10, results[i].Value); + } + } + + [Fact] + public async Task SaveChanges_InsertAllScalarTypes() + { + var guid = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var date = new DateOnly(2024, 6, 15); + var dateTime = new DateTime(2024, 6, 15, 10, 30, 45); + + await using var context = new InsertDbContext(_fixture.ConnectionString); + + context.AllTypes.Add(new InsertAllTypesEntity + { + Id = 300, + ValInt8 = -42, + ValUInt8 = 200, + ValInt16 = -1000, + ValUInt16 = 50000, + ValInt32 = -100000, + ValUInt32 = 3000000000, + ValInt64 = -9000000000000000000L, + ValUInt64 = 15000000000000000000UL, + ValFloat32 = 3.14f, + ValFloat64 = 2.718281828459045, + ValDecimal = 12345.6789m, + ValBool = true, + ValString = "all types test", + ValUuid = guid, + ValDate = date, + ValDatetime = dateTime + }); + + await context.SaveChangesAsync(); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var result = await readContext.AllTypes + .AsNoTracking() + .SingleOrDefaultAsync(e => e.Id == 300); + + Assert.NotNull(result); + Assert.Equal((sbyte)-42, result.ValInt8); + Assert.Equal((byte)200, result.ValUInt8); + Assert.Equal((short)-1000, result.ValInt16); + Assert.Equal((ushort)50000, result.ValUInt16); + Assert.Equal(-100000, result.ValInt32); + Assert.Equal(3000000000u, result.ValUInt32); + Assert.Equal(-9000000000000000000L, result.ValInt64); + Assert.Equal(15000000000000000000UL, result.ValUInt64); + Assert.Equal(3.14f, result.ValFloat32); + Assert.Equal(2.718281828459045, result.ValFloat64, 10); + Assert.Equal(12345.6789m, result.ValDecimal); + Assert.True(result.ValBool); + Assert.Equal("all types test", result.ValString); + Assert.Equal(guid, result.ValUuid); + Assert.Equal(date, result.ValDate); + Assert.Equal(dateTime, result.ValDatetime); + } + + [Fact] + public async Task SaveChanges_InsertWithNulls() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + context.NullableEntities.Add(new NullableInsertEntity + { + Id = 400, + NullableString = null, + NullableInt = null, + NullableDouble = null, + NullableBool = null, + NullableDatetime = null + }); + + context.NullableEntities.Add(new NullableInsertEntity + { + Id = 401, + NullableString = "not null", + NullableInt = 42, + NullableDouble = 3.14, + NullableBool = true, + NullableDatetime = new DateTime(2024, 1, 1, 12, 0, 0) + }); + + await context.SaveChangesAsync(); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var results = await readContext.NullableEntities + .Where(e => e.Id >= 400 && e.Id <= 401) + .OrderBy(e => e.Id) + .AsNoTracking() + .ToListAsync(); + + Assert.Equal(2, results.Count); + + // Null row + Assert.Null(results[0].NullableString); + Assert.Null(results[0].NullableInt); + Assert.Null(results[0].NullableDouble); + Assert.Null(results[0].NullableBool); + Assert.Null(results[0].NullableDatetime); + + // Non-null row + Assert.Equal("not null", results[1].NullableString); + Assert.Equal(42, results[1].NullableInt); + Assert.Equal(3.14, results[1].NullableDouble); + Assert.True(results[1].NullableBool); + Assert.Equal(new DateTime(2024, 1, 1, 12, 0, 0), results[1].NullableDatetime); + } + + [Fact] + public async Task SaveChanges_EntityState_IsUnchangedAfterSave() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + var entity = new InsertEntity { Id = 500, Name = "state_test", Value = 99 }; + context.InsertEntities.Add(entity); + + Assert.Equal(EntityState.Added, context.Entry(entity).State); + + await context.SaveChangesAsync(); + + Assert.Equal(EntityState.Unchanged, context.Entry(entity).State); + } + + [Fact] + public async Task SaveChanges_UpdateThrows() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + // First insert + var entity = new InsertEntity { Id = 600, Name = "original", Value = 1 }; + context.InsertEntities.Add(entity); + await context.SaveChangesAsync(); + + // Now modify and try to save + entity.Name = "modified"; + + var ex = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + Assert.Contains("UPDATE", ex.Message); + } + + [Fact] + public async Task SaveChanges_DeleteThrows() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + // First insert + var entity = new InsertEntity { Id = 700, Name = "to_delete", Value = 1 }; + context.InsertEntities.Add(entity); + await context.SaveChangesAsync(); + + // Now remove and try to save + context.InsertEntities.Remove(entity); + + var ex = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + Assert.Contains("DELETE", ex.Message); + } + + [Fact] + public async Task BulkInsertAsync_InsertsEntities() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + var entities = Enumerable.Range(800, 10) + .Select(i => new InsertEntity { Id = i, Name = $"bulk_{i}", Value = i }) + .ToList(); + + var rowCount = await context.BulkInsertAsync(entities); + Assert.Equal(10, rowCount); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var results = await readContext.InsertEntities + .Where(e => e.Id >= 800 && e.Id < 810) + .OrderBy(e => e.Id) + .AsNoTracking() + .ToListAsync(); + + Assert.Equal(10, results.Count); + for (var i = 0; i < 10; i++) + { + Assert.Equal(800 + i, results[i].Id); + Assert.Equal($"bulk_{800 + i}", results[i].Name); + } + } + + [Fact] + public async Task SaveChanges_AcceptChangesOnSuccessFalse_EntitiesStayAdded() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + var entity = new InsertEntity { Id = 550, Name = "no_accept", Value = 1 }; + context.InsertEntities.Add(entity); + + Assert.Equal(EntityState.Added, context.Entry(entity).State); + + await context.SaveChangesAsync(acceptAllChangesOnSuccess: false); + + // Entity should remain Added because we told EF not to accept changes + Assert.Equal(EntityState.Added, context.Entry(entity).State); + } + + [Fact] + public async Task SaveChanges_ServerGeneratedValue_Throws() + { + await using var context = new ServerGenDbContext(_fixture.ConnectionString); + + context.Set().Add(new ServerGenEntity { Name = "test" }); + + var ex = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + Assert.Contains("Server-generated values", ex.Message); + } + + [Fact] + public async Task SaveChanges_EmptyChangeset_IsNoOp() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + // No changes tracked — should complete without error + var result = await context.SaveChangesAsync(); + Assert.Equal(0, result); + } + + [Fact] + public async Task BulkInsertAsync_LargeBatch() + { + await using var context = new InsertDbContext(_fixture.ConnectionString); + + var entities = Enumerable.Range(1000, 1500) + .Select(i => new InsertEntity { Id = i, Name = $"large_{i}", Value = i % 100 }) + .ToList(); + + var rowCount = await context.BulkInsertAsync(entities); + Assert.Equal(1500, rowCount); + + await using var readContext = new InsertDbContext(_fixture.ConnectionString); + var count = await readContext.InsertEntities + .Where(e => e.Id >= 1000 && e.Id < 2500) + .CountAsync(); + + Assert.Equal(1500, count); + } +} diff --git a/test/EFCore.ClickHouse.Tests/WritePathFailFastTests.cs b/test/EFCore.ClickHouse.Tests/WritePathFailFastTests.cs index 47f37df..d0f0aba 100644 --- a/test/EFCore.ClickHouse.Tests/WritePathFailFastTests.cs +++ b/test/EFCore.ClickHouse.Tests/WritePathFailFastTests.cs @@ -6,13 +6,31 @@ namespace EFCore.ClickHouse.Tests; public class WritePathFailFastTests { [Fact] - public async Task SaveChanges_Throws_NotSupportedException() + public async Task SaveChanges_Update_Throws_NotSupportedException() { await using var context = new WritePathDbContext(); - context.Entities.Add(new WritePathEntity { Id = 1, Name = "test" }); + + // Attach an existing entity and modify it to trigger UPDATE + var entity = new WritePathEntity { Id = 1, Name = "original" }; + context.Entities.Attach(entity); + entity.Name = "modified"; + + var ex = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + Assert.Contains("UPDATE", ex.Message); + } + + [Fact] + public async Task SaveChanges_Delete_Throws_NotSupportedException() + { + await using var context = new WritePathDbContext(); + + // Attach and remove to trigger DELETE + var entity = new WritePathEntity { Id = 1, Name = "test" }; + context.Entities.Attach(entity); + context.Entities.Remove(entity); var ex = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); - Assert.Contains("read-only query scenarios", ex.Message); + Assert.Contains("DELETE", ex.Message); } private sealed class WritePathDbContext : DbContext