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