Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
3 changes: 2 additions & 1 deletion .github/workflows/tests-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ jobs:
- name: Setup ClickHouse
run: |
curl https://clickhouse.com/ | sh
./clickhouse server --daemon
sudo ./clickhouse install --noninteractive
sudo clickhouse start
- uses: actions/cache@v5
name: Cache NuGet
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/tests-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ jobs:
shell: wsl-bash -u root {0}
run: |
curl https://clickhouse.com/ | sh
./clickhouse server --daemon
./clickhouse install --noninteractive
clickhouse start
- uses: actions/cache@v5
name: Cache NuGet
Expand Down
65 changes: 58 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
## Repository Overview

### Project Context
- **ClickHouse.Driver** is the official ADO.NET client for ClickHouse database
- **ClickHouse.Driver** is the official .NET client for ClickHouse database
- **Primary API**: `ClickHouseClient` - thread-safe, singleton-friendly, recommended for most use cases
- **ADO.NET API**: `ClickHouseConnection`/`ClickHouseCommand` - for ORM compatibility (Dapper, EF Core, linq2db)
- **Critical priorities**: Stability, correctness, performance, and comprehensive testing
- **Tech stack**: C#/.NET targeting `net6.0`, `net8.0`, `net9.0`, `net10.0`
- **Tests run on**: `net6.0`, `net8.0`, `net9.0`, `net10.0`; Integration tests: `net10.0`; Benchmarks: `net10.0`
Expand All @@ -12,25 +14,52 @@
```
ClickHouse.Driver.sln
├── ClickHouse.Driver/ # Main library (NuGet package)
│ ├── ADO/ # Core ADO.NET (Connection, Command, DataReader, Parameters)
│ ├── Utility/ # ClickHouseClient (primary API), schema, feature detection
│ ├── ADO/ # ADO.NET layer (Connection, Command, DataReader, Parameters)
│ ├── Types/ # 60+ ClickHouse type implementations + TypeConverter.cs
│ ├── Copy/ # Bulk copy & binary serialization
│ ├── Copy/ # Binary serialization (used internally by ClickHouseClient)
│ ├── Http/ # HTTP layer & connection pooling
│ ├── Utility/ # Schema, feature detection, extensions
│ └── PublicAPI/ # Public API surface tracking (analyzer-enforced)
├── ClickHouse.Driver.Tests/ # NUnit tests (multi-framework)
├── ClickHouse.Driver.IntegrationTests/ # Integration tests (net10.0)
└── ClickHouse.Driver.Benchmark/ # BenchmarkDotNet performance tests
```

### Key Files
- **Primary API**: `ClickHouseClient.cs` - main entry point for most applications
- **Type system**: `Types/TypeConverter.cs` (14KB, complex), `Types/Grammar/` (type parsing)
- **Core ADO**: `ADO/ClickHouseConnection.cs`, `ADO/ClickHouseCommand.cs`, `ADO/Readers/`
- **Protocol**: Binary serialization in `Copy/Serializer/`, HTTP formatting in `Formats/`
- **ADO.NET layer**: `ADO/ClickHouseConnection.cs`, `ADO/ClickHouseCommand.cs`, `ADO/Readers/`
- **Feature detection**: `Utility/ClickHouseFeatureMap.cs` (version-based capabilities)
- **Public API**: `PublicAPI/*.txt` (Roslyn analyzer enforces shipped signatures)
- **Config**: `.editorconfig` (file-scoped namespaces, StyleCop suppressions)

### API Architecture

**ClickHouseClient** (recommended):
```csharp
using var client = new ClickHouseClient("Host=localhost");
await client.ExecuteNonQueryAsync("CREATE TABLE ...");
await client.InsertBinaryAsync(tableName, columns, rows); // High-performance bulk insert
using var reader = await client.ExecuteReaderAsync("SELECT ...");
var scalar = await client.ExecuteScalarAsync("SELECT count() ...");
```

**ClickHouseConnection** (for ORMs):
```csharp
// Use ClickHouseDataSource for proper connection lifetime management with ORMs
var dataSource = new ClickHouseDataSource("Host=localhost");
services.AddSingleton(dataSource);

// Dapper, EF Core, linq2db work with DbConnection
using var connection = dataSource.CreateConnection();
var users = connection.Query<User>("SELECT * FROM users");
```

**Key differences**:
- `ClickHouseClient`: Thread-safe, can be singleton, has `InsertBinaryAsync` for bulk inserts
- `ClickHouseConnection`: ADO.NET `DbConnection`, required for ORM compatibility
- `ClickHouseBulkCopy`: **Deprecated** - use `ClickHouseClient.InsertBinaryAsync` instead

---

## Development Guidelines
Expand Down Expand Up @@ -68,9 +97,31 @@ ClickHouse.Driver.sln
- **Analyzers**: Respect `.editorconfig`, StyleCop suppressions, nullable contexts

### Configuration & Settings
- **Configuration**: happens through connection string and ClickHouseClientSettings
- **Client configuration**: Connection string or `ClickHouseClientSettings` for client-level settings
- **Per-query options**: `QueryOptions` for query-specific settings (QueryId, CustomSettings, Roles, BearerToken)
- **Parameters**: Use `ClickHouseParameterCollection` with `ClickHouseDbParameter` for parameterized queries
- **Feature flags**: Consider adding optional behavior behind connection string settings

```csharp
// Client-level settings
var settings = new ClickHouseClientSettings("Host=localhost");
settings.CustomSettings.Add("max_threads", 4);
using var client = new ClickHouseClient(settings);

// Per-query options
var options = new QueryOptions
{
QueryId = "my-query-id",
CustomSettings = new Dictionary<string, object> { ["max_execution_time"] = 30 },
};
await client.ExecuteReaderAsync("SELECT ...", options: options);

// Parameters
var parameters = new ClickHouseParameterCollection();
parameters.Add("id", 42UL);
await client.ExecuteReaderAsync("SELECT * FROM t WHERE id = {id:UInt64}", parameters);
```

### Observability & Diagnostics
- **Error messages**: Must be clear, actionable, include context (connection string, query, server version)
- **OpenTelemetry**: Changes to diagnostic paths should maintain telemetry integration
Expand Down
150 changes: 69 additions & 81 deletions ClickHouse.Driver.Tests/ADO/ConnectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public async Task ClientShouldSetQueryId()
public async Task ClientShouldSetUserAgent()
{
var headers = new HttpRequestMessage().Headers;
connection.AddDefaultHttpHeaders(headers);
client.AddDefaultHttpHeaders(headers);
// Build assembly version defaults to 1.0.0
Assert.That(headers.UserAgent.ToString().Contains("ClickHouse.Driver/1.0.0"), Is.True);
}
Expand Down Expand Up @@ -335,22 +335,6 @@ public void ChangeDatabaseShouldChangeDatabase()
Assert.That(conn.Database, Is.EqualTo("default"));
}

[Test]
public void ShouldExcludePasswordFromRedactedConnectionString()
{
const string MOCK = "verysecurepassword";
var settings = new ClickHouseClientSettings()
{
Password = MOCK,
};
using var conn = new ClickHouseConnection(settings);
Assert.Multiple(() =>
{
Assert.That(conn.ConnectionString, Contains.Substring($"Password={MOCK}"));
Assert.That(conn.RedactedConnectionString, Is.Not.Contains($"Password={MOCK}"));
});
}

[Test]
[TestCase("https")]
[TestCase("http")]
Expand Down Expand Up @@ -386,8 +370,8 @@ public async Task ShouldUseQueryIdForRawStream()
{
var queryId = Guid.NewGuid().ToString();
var httpResponseMessage = await connection.PostStreamAsync("SELECT version()", (_, _) => Task.CompletedTask, false, CancellationToken.None, queryId);

Assert.That(ClickHouseConnection.ExtractQueryId(httpResponseMessage), Is.EqualTo(queryId));
var queryResult = new QueryResult(httpResponseMessage);
Assert.That(queryResult.QueryId, Is.EqualTo(queryId));
}

private static string[] GetColumnNames(DataTable table) => table.Columns.Cast<DataColumn>().Select(dc => dc.ColumnName).ToArray();
Expand Down Expand Up @@ -665,97 +649,101 @@ public async Task Constructor_WithSettingsWithPath_ShouldApplyPath()
}

[Test]
public void InsertRawStreamAsync_WithNullTable_ShouldThrowArgumentException()
public void Dispose_WithOwnedClient_ShouldDisposeClient()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("1,2,3"));
var ex = Assert.ThrowsAsync<ArgumentException>(async () =>
await connection.InsertRawStreamAsync(table: null, stream: stream, format: "CSV"));
Assert.That(ex.ParamName, Is.EqualTo("table"));
}
// Arrange - connection created from connection string owns its client
var conn = new ClickHouseConnection("Host=localhost");
var client = conn.ClickHouseClient;

[Test]
public void InsertRawStreamAsync_WithEmptyTable_ShouldThrowArgumentException()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("1,2,3"));
var ex = Assert.ThrowsAsync<ArgumentException>(async () =>
await connection.InsertRawStreamAsync(table: "", stream: stream, format: "CSV"));
Assert.That(ex.ParamName, Is.EqualTo("table"));
}
// Act
conn.Dispose();

[Test]
public void InsertRawStreamAsync_WithNullStream_ShouldThrowArgumentNullException()
{
var ex = Assert.ThrowsAsync<ArgumentNullException>(async () =>
await connection.InsertRawStreamAsync(table: "test", stream: null, format: "CSV"));
Assert.That(ex.ParamName, Is.EqualTo("stream"));
// Assert - client should be disposed (calling Dispose again should be safe but we can't easily verify)
// We verify indirectly by checking that a new connection with the same client would fail
// after the original connection disposed it
Assert.DoesNotThrow(() => client.Dispose()); // Dispose should be idempotent
}

[Test]
public void InsertRawStreamAsync_WithNullFormat_ShouldThrowArgumentException()
public void Dispose_WithSharedClient_ShouldNotDisposeClient()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("1,2,3"));
var ex = Assert.ThrowsAsync<ArgumentException>(async () =>
await connection.InsertRawStreamAsync(table: "test", stream: stream, format: null));
Assert.That(ex.ParamName, Is.EqualTo("format"));
}
// Arrange - create a shared client
using var sharedClient = new ClickHouseClient("Host=localhost");

[Test]
public void InsertRawStreamAsync_WithEmptyFormat_ShouldThrowArgumentException()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("1,2,3"));
var ex = Assert.ThrowsAsync<ArgumentException>(async () =>
await connection.InsertRawStreamAsync(table: "test", stream: stream, format: ""));
Assert.That(ex.ParamName, Is.EqualTo("format"));
// Create two connections sharing the same client
var conn1 = new ClickHouseConnection(sharedClient);
var conn2 = new ClickHouseConnection(sharedClient);

// Act - dispose both connections
conn1.Dispose();
conn2.Dispose();

// Assert - shared client should still be usable (not disposed)
// Ping will fail since there's no server, but it shouldn't throw ObjectDisposedException
Assert.DoesNotThrowAsync(async () => await sharedClient.PingAsync());
}

[Test]
public async Task PingAsync_ReturnsTrue_WhenServerResponds()
public void ApplySettings_WithSharedClient_ShouldNotDisposeOriginalClient()
{
var trackingHandler = new TrackingHandler(request =>
{
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("Ok.\n") };
});
using var httpClient = new HttpClient(trackingHandler);
var settings = new ClickHouseClientSettings { Host = "localhost", HttpClient = httpClient };
using var conn = new ClickHouseConnection(settings);
await conn.OpenAsync();
// Arrange - create a shared client
using var sharedClient = new ClickHouseClient("Host=localhost");
var conn = new ClickHouseConnection(sharedClient);

var result = await conn.PingAsync();
// Act - change connection string (which calls ApplySettings internally)
conn.ConnectionString = "Host=otherhost";

Assert.That(result, Is.True);
Assert.That(trackingHandler.Requests.Last().RequestUri.PathAndQuery, Is.EqualTo("/ping"));
// Assert - shared client should still be usable (not disposed)
Assert.DoesNotThrowAsync(async () => await sharedClient.PingAsync());
}

[Test]
public async Task PingAsync_ReturnsFalse_WhenServerReturnsError()
public void ApplySettings_AfterChange_ShouldOwnNewClient()
{
var trackingHandler = new TrackingHandler(request =>
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
});
using var httpClient = new HttpClient(trackingHandler);
var settings = new ClickHouseClientSettings { Host = "localhost", HttpClient = httpClient };
using var conn = new ClickHouseConnection(settings);
await conn.OpenAsync();
// Arrange - start with shared client
using var sharedClient = new ClickHouseClient("Host=localhost");
var conn = new ClickHouseConnection(sharedClient);

// Act - change connection string creates a new owned client
conn.ConnectionString = "Host=otherhost";
var newClient = conn.ClickHouseClient;

var result = await conn.PingAsync();
// Assert - new client should be different from shared client
Assert.That(newClient, Is.Not.SameAs(sharedClient));

Assert.That(result, Is.False);
// Dispose connection - should dispose the new client (not the shared one)
conn.Dispose();
Assert.DoesNotThrowAsync(async () => await sharedClient.PingAsync());
}

[Test]
public void PingAsync_ThrowsInvalidOperationException_WhenConnectionNotOpen()
public void ChangeDatabase_ShouldBePerConnection()
{
using var conn = new ClickHouseConnection("Host=localhost");
// Arrange - create shared client with default database
using var sharedClient = new ClickHouseClient("Host=localhost;Database=default");
using var conn1 = new ClickHouseConnection(sharedClient);
using var conn2 = new ClickHouseConnection(sharedClient);

Assert.ThrowsAsync<InvalidOperationException>(() => conn.PingAsync());
// Act - change database on conn1 only
conn1.ChangeDatabase("other_db");

// Assert - conn1 should have new database, conn2 should still have default
Assert.That(conn1.Database, Is.EqualTo("other_db"));
Assert.That(conn2.Database, Is.EqualTo("default"));
}

[Test]
public async Task PingAsync_WithRealServer_ReturnsTrue()
public void ChangeDatabase_ShouldNotAffectClientSettings()
{
var result = await connection.PingAsync();
// Arrange
using var sharedClient = new ClickHouseClient("Host=localhost;Database=default");
using var conn = new ClickHouseConnection(sharedClient);

// Act
conn.ChangeDatabase("other_db");

Assert.That(result, Is.True);
// Assert - client settings should be unchanged
Assert.That(conn.Database, Is.EqualTo("other_db"));
Assert.That(sharedClient.Settings.Database, Is.EqualTo("default"));
}
}
22 changes: 0 additions & 22 deletions ClickHouse.Driver.Tests/ADO/SessionConnectionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,26 +113,4 @@ public async Task Session_WithCustomHttpClient_ShouldWork()
await connection.ExecuteStatementAsync("CREATE TEMPORARY TABLE test_temp_table (value UInt8)");
await connection.ExecuteScalarAsync("SELECT COUNT(*) from test_temp_table");
}

[Test]
public async Task Session_ConcurrentRequests_AreSerialized()
{
var sessionId = "TEST-" + Guid.NewGuid();
var marker = Guid.NewGuid().ToString("N");

using var connection = (ClickHouseConnection)CreateConnection(useSession: true, sessionId);

var stopwatch = System.Diagnostics.Stopwatch.StartNew();

// Two 300ms sleep queries with markers we can find in query_log
var task1 = connection.ExecuteScalarAsync($"SELECT sleep(0.3), 'marker1_{marker}'");
var task2 = connection.ExecuteScalarAsync($"SELECT sleep(0.3), 'marker2_{marker}'");

await Task.WhenAll(task1, task2);
stopwatch.Stop();

// Quick sanity check: should take >550ms if serialized
Assert.That(stopwatch.ElapsedMilliseconds, Is.GreaterThan(550),
$"Requests should be serialized. Expected >550ms but took {stopwatch.ElapsedMilliseconds}ms");
}
}
14 changes: 9 additions & 5 deletions ClickHouse.Driver.Tests/AbstractConnectionTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ namespace ClickHouse.Driver.Tests;
public abstract class AbstractConnectionTestFixture : IDisposable
{
protected readonly ClickHouseConnection connection;
protected readonly ClickHouseClient client;

protected AbstractConnectionTestFixture()
{
connection = TestUtilities.GetTestClickHouseConnection();
using var command = connection.CreateCommand();
command.CommandText = "CREATE DATABASE IF NOT EXISTS test;";
command.ExecuteScalar();
client = TestUtilities.GetTestClickHouseClient();
connection = client.CreateConnection();
client.ExecuteNonQueryAsync("CREATE DATABASE IF NOT EXISTS test;").GetAwaiter().GetResult();
}

protected static string SanitizeTableName(string input)
Expand All @@ -31,5 +31,9 @@ protected static string SanitizeTableName(string input)
}

[OneTimeTearDown]
public void Dispose() => connection?.Dispose();
public void Dispose()
{
connection?.Dispose();
client?.Dispose();
}
}
2 changes: 1 addition & 1 deletion ClickHouse.Driver.Tests/ActivitySourceHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public void ShouldCreateActivity()
};
ActivitySource.AddActivityListener(listener);

using var activity = connection.StartActivity("TestActivity");
using var activity = client.StartActivity("TestActivity");
ClassicAssert.NotNull(activity);
}

Expand Down
Loading
Loading