Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,32 +52,55 @@ protected override DbConnection CreateDbConnection()
///
/// The ClickHouse.Driver HTTP protocol is stateless by default (<c>UseSession=False</c>):
/// a standalone <c>SET join_use_nulls = 1</c> statement does not persist to subsequent
/// queries. Instead the driver applies <c>set_*</c> connection-string parameters as URL
/// parameters on every query, so we must mutate the connection string itself.
/// queries. Instead the driver applies <c>set_*</c> parameters (the driver's
/// <c>CustomSettings</c>) as URL parameters on every query, so we must record the setting on
/// the connection before it is opened.
Comment on lines +57 to +59

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45edc2d — reworded the summary to state that the setting is recorded on ClickHouseConnection.Settings, with the connection-string path used only as a fallback for a non-ClickHouse DbConnection.

///
/// Applies on all paths. For the connection-string path the setting is already baked in by
/// <see cref="ClickHouseDataSourceManager.EnsureDefaultSettings"/>; this override covers
/// the <see cref="DbConnection"/> and <see cref="DbDataSource"/> paths where EF hands us
/// connection objects we didn't construct. We only mutate when the connection is Closed
/// and the user has not explicitly configured <c>join_use_nulls</c>.
///
/// For a <see cref="ClickHouseConnection"/> we inject the setting by copying and reassigning
/// its <see cref="ClickHouseConnection.Settings"/> rather than rewriting
/// <see cref="DbConnection.ConnectionString"/>. Assigning the connection string rebuilds the
/// driver's settings purely from the string, silently discarding code-only settings such as
/// <c>SkipServerCertificateValidation</c> or a custom <c>HttpClient</c>. Only the string
/// fallback (for a non-ClickHouse <see cref="DbConnection"/>) mutates the connection string.
/// </summary>
public override bool Open(bool errorsExpected = false)
{
EnsureJoinUseNullsInConnectionString();
EnsureJoinUseNulls();
return base.Open(errorsExpected);
}

public override Task<bool> OpenAsync(CancellationToken cancellationToken, bool errorsExpected = false)
{
EnsureJoinUseNullsInConnectionString();
EnsureJoinUseNulls();
return base.OpenAsync(cancellationToken, errorsExpected);
}

private void EnsureJoinUseNullsInConnectionString()
private void EnsureJoinUseNulls()
{
if (_joinNullSemanticsDisabled || DbConnection.State != ConnectionState.Closed)
return;

// Prefer mutating the strongly-typed settings so that code-only settings (which have no
// connection-string equivalent) are preserved. Rewriting ConnectionString would rebuild
// the driver's settings from the string alone and drop them.
if (DbConnection is ClickHouseConnection clickHouseConnection)
{
var settings = clickHouseConnection.Settings;
if (settings.CustomSettings.ContainsKey(JoinUseNullsSetting))
return;

var updatedSettings = new ClickHouseClientSettings(settings);
updatedSettings.CustomSettings[JoinUseNullsSetting] = "1";
clickHouseConnection.Settings = updatedSettings;
return;
}

var cs = DbConnection.ConnectionString;
if (string.IsNullOrEmpty(cs)
|| cs.Contains("join_use_nulls", StringComparison.OrdinalIgnoreCase))
Expand All @@ -88,26 +111,58 @@ private void EnsureJoinUseNullsInConnectionString()
DbConnection.ConnectionString = ClickHouseDataSourceManager.EnsureDefaultSettings(cs);
}

private const string JoinUseNullsSetting = "join_use_nulls";

public IClickHouseRelationalConnection CreateMasterConnection()
{
var connectionStringBuilder = new ClickHouseConnectionStringBuilder(
_dataSource?.ConnectionString ?? ConnectionString)
{
Database = "default"
};
var optionsBuilder = new DbContextOptionsBuilder();

var masterConnectionString = connectionStringBuilder.ConnectionString;
if (TryGetClickHouseClientSettings(out var settings))
{
// Clone the driver settings and only swap the database. Round-tripping through a
// connection string here would drop code-only settings that have no connection-string
// representation (SkipServerCertificateValidation, BearerToken, HttpClient,
// HttpClientFactory, LoggerFactory, CustomHeaders, ApplicationInfo, EnableDebugMode,
// and the parameter/read converters), breaking database create/drop against servers
// that depend on them (e.g. a self-signed certificate). The connection is owned by the
// context so it is disposed with the master connection.
var masterSettings = new ClickHouseClientSettings(settings) { Database = "default" };
optionsBuilder.UseClickHouse(new ClickHouseConnection(masterSettings), contextOwnsConnection: true);
}
else
{
var masterConnectionString = new ClickHouseConnectionStringBuilder(ConnectionString)
{
Database = "default"
}.ConnectionString;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 45edc2d. The fallback now uses _dataSource?.ConnectionString ?? ConnectionString, so a non-ClickHouse DbDataSource (which sets no connection string on the EF options) builds a valid master connection string. Added CreateMasterConnection_NonClickHouseDataSource_UsesDataSourceConnectionString to cover it.


var contextOptions = new DbContextOptionsBuilder()
.UseClickHouse(masterConnectionString)
.Options;
optionsBuilder.UseClickHouse(masterConnectionString);
}

return new ClickHouseRelationalConnection(
Dependencies with { ContextOptions = contextOptions },
Dependencies with { ContextOptions = optionsBuilder.Options },
dataSource: null,
_joinNullSemanticsDisabled);
}

private bool TryGetClickHouseClientSettings(out ClickHouseClientSettings settings)
{
if (_dataSource is ClickHouseDataSource clickHouseDataSource)
{
settings = clickHouseDataSource.Settings;
return true;
}

if (DbConnection is ClickHouseConnection clickHouseConnection)
{
settings = clickHouseConnection.Settings;
return true;
}

settings = null!;
return false;
}

public override IDbContextTransaction BeginTransaction(IsolationLevel isolationLevel)
=> new ClickHouseTransaction();

Expand Down
117 changes: 117 additions & 0 deletions test/EFCore.ClickHouse.Tests/ConnectionSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using ClickHouse.Driver.ADO;
using ClickHouse.EntityFrameworkCore.Storage.Internal;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Xunit;

namespace EFCore.ClickHouse.Tests;
Expand Down Expand Up @@ -96,6 +98,121 @@ public async Task UseClickHouse_DbConnection_RespectsUserOptOut()
Assert.False(await GetJoinUseNullsAsync(ctx));
}

[Fact]
public async Task UseClickHouse_DbConnection_PreservesCodeOnlySettings()
{
// Settings that only exist in code (no connection-string equivalent) must survive the
// join_use_nulls injection performed on Open. Previously
// the provider rewrote ConnectionString, which rebuilt the driver's settings from the
// string alone and silently dropped SkipServerCertificateValidation.
var settings = new ClickHouseClientSettings(_fixture.ConnectionString)
{
SkipServerCertificateValidation = true
};

await using var connection = new ClickHouseConnection(settings);
await using var ctx = new MinimalContext(o => o.UseClickHouse(connection));

Assert.True(await GetJoinUseNullsAsync(ctx));
Assert.True(connection.Settings.SkipServerCertificateValidation);
}

[Fact]
public async Task UseClickHouse_DbDataSource_PreservesCodeOnlySettings()
{
var settings = new ClickHouseClientSettings(_fixture.ConnectionString)
{
SkipServerCertificateValidation = true
};

await using var dataSource = new ClickHouseDataSource(settings);
await using var ctx = new MinimalContext(o => o.UseClickHouse(dataSource));

Assert.True(await GetJoinUseNullsAsync(ctx));

var connection = (ClickHouseConnection)ctx.Database.GetDbConnection();
Assert.True(connection.Settings.SkipServerCertificateValidation);
}

[Fact]
public void CreateMasterConnection_DbDataSource_PreservesCodeOnlySettings()
{
// The master connection (used for CREATE/DROP DATABASE) must not lose code-only settings
// by round-tripping through a connection string. Exercises several settings that have no
// connection-string representation, not just SkipServerCertificateValidation.
var settings = new ClickHouseClientSettings(_fixture.ConnectionString)
{
SkipServerCertificateValidation = true,
BearerToken = "token-abc",
CustomHeaders = new Dictionary<string, string> { ["X-Custom"] = "value" }
};

using var dataSource = new ClickHouseDataSource(settings);
using var ctx = new MinimalContext(o => o.UseClickHouse(dataSource));

var master = ctx.Database.GetService<IClickHouseRelationalConnection>().CreateMasterConnection();
try
{
var masterConnection = (ClickHouseConnection)master.DbConnection;
Assert.Equal("default", masterConnection.Settings.Database);
Assert.True(masterConnection.Settings.SkipServerCertificateValidation);
Assert.Equal("token-abc", masterConnection.Settings.BearerToken);
Assert.Equal("value", masterConnection.Settings.CustomHeaders["X-Custom"]);
}
finally
{
master.Dispose();
}
}

[Fact]
public void CreateMasterConnection_PreservesUserJoinUseNullsOptOut()
{
// If the user opted out via set_join_use_nulls=0, the master connection must carry that
// through so the injection on Open sees it and does not override the choice.
var optOutConnectionString = _fixture.ConnectionString
+ (_fixture.ConnectionString.EndsWith(';') ? "" : ";")
+ "set_join_use_nulls=0";

using var connection = new ClickHouseConnection(optOutConnectionString);
using var ctx = new MinimalContext(o => o.UseClickHouse(connection));

var master = ctx.Database.GetService<IClickHouseRelationalConnection>().CreateMasterConnection();
try
{
var masterConnection = (ClickHouseConnection)master.DbConnection;
Assert.Equal("0", masterConnection.Settings.CustomSettings["join_use_nulls"].ToString());
}
finally
{
master.Dispose();
}
}

[Fact]
public void CreateMasterConnection_DbConnection_PreservesCodeOnlySettings()
{
var settings = new ClickHouseClientSettings(_fixture.ConnectionString)
{
SkipServerCertificateValidation = true
};

using var connection = new ClickHouseConnection(settings);
using var ctx = new MinimalContext(o => o.UseClickHouse(connection));

var master = ctx.Database.GetService<IClickHouseRelationalConnection>().CreateMasterConnection();
try
{
var masterConnection = (ClickHouseConnection)master.DbConnection;
Assert.Equal("default", masterConnection.Settings.Database);
Assert.True(masterConnection.Settings.SkipServerCertificateValidation);
}
finally
{
master.Dispose();
}
}

private static async Task<bool> GetJoinUseNullsAsync(DbContext ctx)
{
var connection = ctx.Database.GetDbConnection();
Expand Down
Loading