From 174cee3cee0cb92a22716dfea43ad9082839fbca Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 28 Aug 2026 23:38:34 +0200 Subject: [PATCH 1/6] Freeze the native client's public API and guard its endianness assumption Epic R1-R4 plus C9. The TCP client's public surface is now declared and enforced, and every public member is documented. Surface changes, from the review of what to expose: - Every public type moves into the ClickHouse.Driver.Tcp namespace, so a caller needs one using rather than four. IDynamicColumn, IVariantColumn and ColumnElementTypes get their own files to allow it. - ClickHouseDecimal becomes ClickHouseTcpDecimal. The HTTP driver already publishes a ClickHouseDecimal, and one package cannot carry two public types of that name without confusing every caller. - ClickHouseTcpColumn.Create builds the columns InsertAsync takes. Until now every concrete column was internal and there was no factory, so the method was public but uncallable from outside the assembly. Columns built this way carry no type name: the insert reads the target's type from the server's schema, so asking the caller for one only invites a wrong answer we would ignore. - GetServerInfoAsync reports the handshake's version, protocol revision and timezone, which nothing exposed before. - ExecuteScalarAsync returns the first cell of the first row. - ClickHouseTcpDataSource owns a client and hands out views whose disposal does nothing, so a scoped consumer cannot close a pool it does not own. C9: the column codecs reinterpret wire bytes as CLR values, which holds only on a little-endian host. One check at client and connection construction replaces the per-codec guards. The analyzer covers ClickHouse.Driver.Tcp and ClickHouse.Driver.Common, each with an .editorconfig raising RS0016/RS0017/RS0024 to error. RS0048 is an error too: the analyzer needs exactly one Shipped/Unshipped pair and runs no surface check at all when given one file or two pairs, and RS0048 is the only sign it has stopped looking. ClickHouse.Driver is left alone; wiring it needs ~230 symbols declared first, and is filed separately. Co-Authored-By: Claude Opus 5 (1M context) --- ClickHouse.Driver.Common/.editorconfig | 5 + .../ClickHouse.Driver.Common.csproj | 10 + .../Client/ClickHouseTcpServerInfoTests.cs | 52 ++ ...lickHouseTcpClientQueryIntegrationTests.cs | 1 - ...ClickHouseTcpDataSourceIntegrationTests.cs | 85 +++ .../PublicSurfaceIntegrationTests.cs | 300 +++++++++++ ...lTests.cs => ClickHouseTcpDecimalTests.cs} | 47 +- .../Numerics/Int256Tests.cs | 1 - .../TcpParameterFormatterEdgeCaseTests.cs | 3 +- .../Parameters/TcpParameterFormatterTests.cs | 3 +- .../ClickHouseBinaryReaderWriterTests.cs | 1 - .../Types/ClickHouseTcpColumnTests.cs | 98 ++++ .../Types/DecimalColumnCodecTests.cs | 31 +- .../Types/DynamicTypeInferenceTests.cs | 9 +- .../Types/FixedWidthColumnCodecTests.cs | 1 - .../Types/NullableColumnCodecTests.cs | 1 - .../Utilities/InsertRoundTripCase.cs | 35 +- ClickHouse.Driver.Tcp/.editorconfig | 5 + .../ClickHouse.Driver.Tcp.csproj | 17 +- .../Client/ClickHouseTcpClient.cs | 39 ++ .../Client/ClickHouseTcpDataSource.cs | 130 +++++ .../Client/ClickHouseTcpServerInfo.cs | 47 ++ .../Client/ClickHouseTcpSession.cs | 11 + .../Client/IClickHouseTcpOperations.cs | 40 ++ ClickHouse.Driver.Tcp/Format/Block.cs | 3 +- ...ouseDecimal.cs => ClickHouseTcpDecimal.cs} | 83 ++- ClickHouse.Driver.Tcp/Numerics/Int256.cs | 26 +- ClickHouse.Driver.Tcp/Numerics/UInt256.cs | 26 +- .../Parameters/ParameterTypeInference.cs | 5 +- .../Parameters/TcpParameterFormatter.cs | 9 +- ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs | 2 +- .../Protocol/ClickHouseBinaryReader.cs | 1 - .../Protocol/ClickHouseBinaryWriter.cs | 1 - .../Protocol/ClickHouseTcpConnection.cs | 3 + .../Protocol/HostEndianness.cs | 28 + .../PublicAPI/PublicAPI.Shipped.txt | 0 .../PublicAPI/PublicAPI.Unshipped.txt | 498 ++++++++++++++++++ .../Types/ClickHouseTcpColumn.cs | 67 +++ .../Types/Codecs/DecimalColumnCodec.cs | 19 +- .../Types/Codecs/DynamicTypeInference.cs | 13 +- .../Types/ColumnCodecRegistry.cs | 1 - .../Types/ColumnElementTypes.cs | 33 ++ ClickHouse.Driver.Tcp/Types/DynamicColumn.cs | 56 -- ClickHouse.Driver.Tcp/Types/IArrayColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/IColumn.cs | 42 +- ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs | 60 +++ .../Types/ILowCardinalityColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/IMapColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/INestedColumn.cs | 2 +- .../Types/INullableColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/ITupleColumn.cs | 2 +- ClickHouse.Driver.Tcp/Types/IVariantColumn.cs | 52 ++ ClickHouse.Driver.Tcp/Types/VariantColumn.cs | 49 -- Directory.Packages.props | 1 + changelog.d/418-tcp-native-client.features.md | 4 + 56 files changed, 1792 insertions(+), 276 deletions(-) create mode 100644 ClickHouse.Driver.Common/.editorconfig create mode 100644 ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpServerInfoTests.cs create mode 100644 ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs create mode 100644 ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs rename ClickHouse.Driver.Tcp.Tests/Numerics/{ClickHouseDecimalTests.cs => ClickHouseTcpDecimalTests.cs} (66%) create mode 100644 ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpColumnTests.cs create mode 100644 ClickHouse.Driver.Tcp/.editorconfig create mode 100644 ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs create mode 100644 ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs rename ClickHouse.Driver.Tcp/Numerics/{ClickHouseDecimal.cs => ClickHouseTcpDecimal.cs} (64%) create mode 100644 ClickHouse.Driver.Tcp/Protocol/HostEndianness.cs create mode 100644 ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Shipped.txt create mode 100644 ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt create mode 100644 ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs create mode 100644 ClickHouse.Driver.Tcp/Types/ColumnElementTypes.cs create mode 100644 ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs create mode 100644 ClickHouse.Driver.Tcp/Types/IVariantColumn.cs create mode 100644 changelog.d/418-tcp-native-client.features.md diff --git a/ClickHouse.Driver.Common/.editorconfig b/ClickHouse.Driver.Common/.editorconfig new file mode 100644 index 000000000..d557f2bed --- /dev/null +++ b/ClickHouse.Driver.Common/.editorconfig @@ -0,0 +1,5 @@ +[*.cs] +dotnet_diagnostic.RS0016.severity = error # RS0016: Public APIs must be declared +dotnet_diagnostic.RS0017.severity = error # RS0017: Remove deleted types and members from the declared API +dotnet_diagnostic.RS0024.severity = error # RS0024: The contents of the public API files are invalid +dotnet_diagnostic.RS0048.severity = error # RS0048: A missing API file turns the checks above off silently diff --git a/ClickHouse.Driver.Common/ClickHouse.Driver.Common.csproj b/ClickHouse.Driver.Common/ClickHouse.Driver.Common.csproj index 2e428d33c..066217f55 100644 --- a/ClickHouse.Driver.Common/ClickHouse.Driver.Common.csproj +++ b/ClickHouse.Driver.Common/ClickHouse.Driver.Common.csproj @@ -39,6 +39,16 @@ + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpServerInfoTests.cs b/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpServerInfoTests.cs new file mode 100644 index 000000000..eab015873 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpServerInfoTests.cs @@ -0,0 +1,52 @@ +using System; + +namespace ClickHouse.Driver.Tcp.Tests.Client; + +// Only what a live server cannot show: the derived Version and ToString, and that a patch the server never +// sent still yields a three-part version rather than throwing. +[TestFixture] +public class ClickHouseTcpServerInfoTests +{ + [Test] + public void Version_ComposesTheThreeParts() + { + var info = new ClickHouseTcpServerInfo { VersionMajor = 25, VersionMinor = 8, VersionPatch = 3 }; + + Assert.That(info.Version, Is.EqualTo(new Version(25, 8, 3))); + } + + [Test] + public void Version_PatchNotSent_IsZeroRatherThanUnset() + { + // The handshake omits the patch below the revision that introduced it, so it defaults to 0. A + // two-part Version would compare unequal to a three-part one, so it has to stay three parts. + var info = new ClickHouseTcpServerInfo { VersionMajor = 25, VersionMinor = 8 }; + + Assert.Multiple(() => + { + Assert.That(info.Version, Is.EqualTo(new Version(25, 8, 0))); + Assert.That(info.Version.Build, Is.EqualTo(0)); + }); + } + + [Test] + public void ToString_RendersNameAndThreePartVersion() + { + var info = new ClickHouseTcpServerInfo { Name = "ClickHouse", VersionMajor = 25, VersionMinor = 8, VersionPatch = 3 }; + + Assert.That(info.ToString(), Is.EqualTo("ClickHouse 25.8.3")); + } + + [Test] + public void TimezoneAndDisplayName_DefaultToEmptyRatherThanNull() + { + // Both are blank when the negotiated revision predates them; empty keeps callers off a null check. + var info = new ClickHouseTcpServerInfo(); + + Assert.Multiple(() => + { + Assert.That(info.Timezone, Is.Empty); + Assert.That(info.DisplayName, Is.Empty); + }); + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientQueryIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientQueryIntegrationTests.cs index 9fd83ce41..e4462ed5b 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientQueryIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientQueryIntegrationTests.cs @@ -4,7 +4,6 @@ using System.Threading; using System.Threading.Tasks; using ClickHouse.Driver.Tcp.Format; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types; namespace ClickHouse.Driver.Tcp.Tests.Integration; diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs new file mode 100644 index 000000000..2eb9096b0 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ClickHouse.Driver.Tcp.Tests.Integration; + +// The point of the data source is ownership, and only a live pool can show it: a view that disposes itself +// must leave the pool usable, and disposing the data source must not. +[TestFixture] +[Category("Integration")] +public class ClickHouseTcpDataSourceIntegrationTests +{ + private static readonly CancellationToken None = CancellationToken.None; + + private static ClickHouseTcpDataSource CreateDataSource() + => new(TcpServerFixture.Options()); + + [Test] + public async Task GetClient_ReturnsTheSameInstanceEveryCall() + { + await using ClickHouseTcpDataSource source = CreateDataSource(); + + Assert.That(source.GetClient(), Is.SameAs(source.GetClient())); + } + + [Test] + public async Task GetClient_DisposedByAConsumer_LeavesThePoolWorking() + { + // A scoped service that disposes what it was injected must not close a pool it does not own. + await using ClickHouseTcpDataSource source = CreateDataSource(); + + IClickHouseTcpClient injected = source.GetClient(); + await injected.PingAsync(None); + await injected.DisposeAsync(); + + object value = await source.GetClient().ExecuteScalarAsync("SELECT 1", cancellationToken: None); + Assert.That(value, Is.EqualTo((byte)1)); + } + + [Test] + public async Task DisposeAsync_ClosesThePool_SoTheViewStopsWorking() + { + ClickHouseTcpDataSource source = CreateDataSource(); + IClickHouseTcpClient client = source.GetClient(); + await client.PingAsync(None); + + await source.DisposeAsync(); + + Assert.ThrowsAsync(async () => await client.PingAsync(None)); + } + + [Test] + public async Task DisposeAsync_CalledTwice_DoesNotThrow() + { + ClickHouseTcpDataSource source = CreateDataSource(); + await source.DisposeAsync(); + + Assert.DoesNotThrowAsync(async () => await source.DisposeAsync()); + } + + [Test] + public async Task OpenSessionAsync_RunsOnThePoolAndIsTheCallersToDispose() + { + await using ClickHouseTcpDataSource source = CreateDataSource(); + + await using (IClickHouseTcpSession session = await source.OpenSessionAsync(None)) + { + await session.ExecuteAsync("CREATE TEMPORARY TABLE ds_session_marker (id UInt8)", cancellationToken: None); + object count = await session.ExecuteScalarAsync("SELECT count() FROM ds_session_marker", cancellationToken: None); + Assert.That(count, Is.EqualTo(0UL)); + } + + // The session closed its connection, but the data source's pool is untouched. + object value = await source.GetClient().ExecuteScalarAsync("SELECT 2", cancellationToken: None); + Assert.That(value, Is.EqualTo((byte)2)); + } + + [Test] + public async Task Options_ReportsTheConfigurationOperationsRunUnder() + { + await using ClickHouseTcpDataSource source = CreateDataSource(); + + Assert.That(source.Options.Host, Is.EqualTo(TcpServerFixture.Host)); + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs new file mode 100644 index 000000000..3149eeb7b --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ClickHouse.Driver.Tcp.Protocol; + +namespace ClickHouse.Driver.Tcp.Tests.Integration; + +// Covers the public conveniences added with the R1-R4 surface pass: server info, ExecuteScalarAsync, and the +// ClickHouseTcpColumn factory. The factory is the only public way to build an insert column, so it needs a real +// insert to prove it: the columns it builds carry no ClickHouse type name, and the target type comes from the +// server's schema instead. +[TestFixture] +[Category("Integration")] +public class PublicSurfaceIntegrationTests +{ + private static readonly CancellationToken None = CancellationToken.None; + + private static string UniqueTableName() => $"tcp_public_surface_{Guid.NewGuid():N}"; + + [Test] + public async Task GetServerInfoAsync_AgainstRealServer_ReportsVersionMatchingSelectVersion() + { + await using var client = TcpServerFixture.CreateClient(); + + ClickHouseTcpServerInfo info = await client.GetServerInfoAsync(None); + var reported = (string)await client.ExecuteScalarAsync("SELECT version()", cancellationToken: None); + var expected = Version.Parse(reported); + + Assert.Multiple(() => + { + Assert.That(info.Name, Is.EqualTo("ClickHouse")); + Assert.That(info.VersionMajor, Is.EqualTo(expected.Major)); + Assert.That(info.VersionMinor, Is.EqualTo(expected.Minor)); + Assert.That(info.VersionPatch, Is.EqualTo(expected.Build)); + Assert.That(info.Timezone, Is.Not.Empty); + + // Negotiated, so it is the client's ceiling whenever the server offers at least that much. + Assert.That(info.ProtocolRevision, Is.EqualTo(NegotiatedProtocol.ClientTcpProtocolVersion)); + }); + } + + [Test] + public async Task GetServerInfoAsync_OnASession_ReportsTheSameServerAsItsClient() + { + await using var client = TcpServerFixture.CreateClient(); + await using IClickHouseTcpSession session = await client.OpenSessionAsync(None); + + ClickHouseTcpServerInfo fromClient = await client.GetServerInfoAsync(None); + ClickHouseTcpServerInfo fromSession = await session.GetServerInfoAsync(None); + + Assert.That(fromSession, Is.EqualTo(fromClient)); + } + + [Test] + public async Task ExecuteScalarAsync_SelectCount_ReturnsTheFirstCellBoxed() + { + await using var client = TcpServerFixture.CreateClient(); + + object value = await client.ExecuteScalarAsync("SELECT count() FROM numbers(7)", cancellationToken: None); + + Assert.That(value, Is.EqualTo(7UL)); + } + + [Test] + public async Task ExecuteScalarAsync_MultipleRowsAndColumns_ReturnsTheFirstColumnOfTheFirstRow() + { + await using var client = TcpServerFixture.CreateClient(); + + object value = await client.ExecuteScalarAsync( + "SELECT number, number * 2 FROM numbers(1000) ORDER BY number", + cancellationToken: None); + + Assert.That(value, Is.EqualTo(0UL)); + } + + [Test] + public async Task ExecuteScalarAsync_EmptyResult_ReturnsNull() + { + await using var client = TcpServerFixture.CreateClient(); + + object value = await client.ExecuteScalarAsync("SELECT 1 WHERE 0", cancellationToken: None); + + Assert.That(value, Is.Null); + } + + [Test] + public async Task ExecuteScalarAsync_AbandonsALargeResult_LeavesTheConnectionReusable() + { + // Stopping after the first row cancels the rest of the result. The connection goes back to the pool, so + // the next operation on the same client must succeed rather than trip over leftover bytes. + await using var client = TcpServerFixture.CreateClient(); + + object first = await client.ExecuteScalarAsync( + "SELECT number FROM numbers(5000000) ORDER BY number", + cancellationToken: None); + object second = await client.ExecuteScalarAsync("SELECT 42", cancellationToken: None); + + Assert.Multiple(() => + { + Assert.That(first, Is.EqualTo(0UL)); + Assert.That(second, Is.EqualTo((byte)42)); + }); + } + + [Test] + public async Task ExecuteScalarAsync_NullCell_ReturnsNull() + { + await using var client = TcpServerFixture.CreateClient(); + + object value = await client.ExecuteScalarAsync( + "SELECT CAST(NULL, 'Nullable(Int32)')", + cancellationToken: None); + + Assert.That(value, Is.Null); + } + + [Test] + public async Task Create_ColumnsForEveryShape_RoundTripThroughAnInsert() + { + // One case per shape the factory has to carry: fixed-width, string, nullable, and a jagged array row. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + try + { + await client.ExecuteAsync( + $"CREATE TABLE {table} (id Int32, name String, note Nullable(String), tags Array(UInt32)) ENGINE = Memory", + cancellationToken: None); + + IColumn[] columns = + { + ClickHouseTcpColumn.Create("id", new[] { 1, 2, 3 }), + ClickHouseTcpColumn.Create("name", new[] { "a", "b", "c" }), + ClickHouseTcpColumn.Create("note", new[] { "x", null, "z" }), + ClickHouseTcpColumn.Create("tags", new[] { new uint[] { 1, 2 }, Array.Empty(), new uint[] { 3 } }), + }; + + await client.InsertAsync($"INSERT INTO {table} (id, name, note, tags) VALUES", columns, cancellationToken: None); + + var rows = new List(); + await foreach (object[] row in client.QueryAsync($"SELECT id, name, note, tags FROM {table} ORDER BY id", cancellationToken: None)) + { + rows.Add(row); + } + + Assert.Multiple(() => + { + Assert.That(rows, Has.Count.EqualTo(3)); + Assert.That(rows[0][0], Is.EqualTo(1)); + Assert.That(rows[0][1], Is.EqualTo("a")); + Assert.That(rows[1][2], Is.Null); + Assert.That(rows[0][3], Is.EqualTo(new uint[] { 1, 2 })); + Assert.That(rows[1][3], Is.EqualTo(Array.Empty())); + Assert.That(rows[2][3], Is.EqualTo(new uint[] { 3 })); + }); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + [Test] + public async Task Create_ColumnsNamedOutOfOrderAndCoveringASubset_MatchTheTargetByName() + { + // The factory takes no type name, so name matching is the whole contract: order is free and an unnamed + // column must fall back to its server-side default. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + try + { + await client.ExecuteAsync( + $"CREATE TABLE {table} (id Int32, name String DEFAULT 'unset') ENGINE = Memory", + cancellationToken: None); + + IColumn[] columns = { ClickHouseTcpColumn.Create("id", new[] { 7 }) }; + await client.InsertAsync($"INSERT INTO {table} (id) VALUES", columns, cancellationToken: None); + + var rows = new List(); + await foreach (object[] row in client.QueryAsync($"SELECT id, name FROM {table}", cancellationToken: None)) + { + rows.Add(row); + } + + Assert.Multiple(() => + { + Assert.That(rows, Has.Count.EqualTo(1)); + Assert.That(rows[0][0], Is.EqualTo(7)); + Assert.That(rows[0][1], Is.EqualTo("unset")); + }); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + [Test] + public async Task Create_ClrTypeTheTargetDoesNotAccept_ThrowsNamingTheColumn() + { + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + try + { + await client.ExecuteAsync($"CREATE TABLE {table} (id Int32) ENGINE = Memory", cancellationToken: None); + + IColumn[] columns = { ClickHouseTcpColumn.Create("id", new[] { "not an int" }) }; + + var ex = Assert.ThrowsAsync(async () => + await client.InsertAsync($"INSERT INTO {table} (id) VALUES", columns, cancellationToken: None)); + + Assert.That(ex.Message, Does.Contain("id").And.Contain("Int32")); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + [Test] + public async Task Create_ReadBackColumnReinserted_NeedsNoFactory() + { + // The read shape is already an insert shape, which is why the factory does not need a dense overload. + await using var client = TcpServerFixture.CreateClient(); + string source = UniqueTableName(); + string destination = UniqueTableName(); + + try + { + await client.ExecuteAsync($"CREATE TABLE {source} (v Array(Int64)) ENGINE = Memory", cancellationToken: None); + await client.ExecuteAsync($"CREATE TABLE {destination} (v Array(Int64)) ENGINE = Memory", cancellationToken: None); + await client.ExecuteAsync($"INSERT INTO {source} VALUES ([1, 2]), ([]), ([3])", cancellationToken: None); + + await foreach (Block block in client.StreamAsync($"SELECT v FROM {source} ORDER BY v", cancellationToken: None)) + { + await client.InsertAsync( + $"INSERT INTO {destination} (v) VALUES", + new[] { block[0] }, + cancellationToken: None); + } + + object copied = await client.ExecuteScalarAsync($"SELECT count() FROM {destination}", cancellationToken: None); + Assert.That(copied, Is.EqualTo(3UL)); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {source}", cancellationToken: None); + await client.ExecuteAsync($"DROP TABLE IF EXISTS {destination}", cancellationToken: None); + } + } + + [Test] + public void Create_NullNameOrValues_Throws() + { + Assert.Multiple(() => + { + Assert.Throws(() => ClickHouseTcpColumn.Create(null, new[] { 1 })); + Assert.Throws(() => ClickHouseTcpColumn.Create("id", (int[])null)); + Assert.Throws(() => ClickHouseTcpColumn.Create("id", (IEnumerable)null)); + }); + } + + [Test] + public async Task Create_FromASequence_EnumeratesOnceAndInsertsTheSameRows() + { + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + try + { + await client.ExecuteAsync($"CREATE TABLE {table} (id Int32) ENGINE = Memory", cancellationToken: None); + + int enumerations = 0; + IEnumerable Sequence() + { + enumerations++; + yield return 10; + yield return 20; + } + + IColumn[] columns = { ClickHouseTcpColumn.Create("id", Sequence()) }; + await client.InsertAsync($"INSERT INTO {table} (id) VALUES", columns, cancellationToken: None); + + object total = await client.ExecuteScalarAsync($"SELECT sum(id) FROM {table}", cancellationToken: None); + + Assert.Multiple(() => + { + Assert.That(total, Is.EqualTo(30L)); + Assert.That(enumerations, Is.EqualTo(1)); + }); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseDecimalTests.cs b/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs similarity index 66% rename from ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseDecimalTests.cs rename to ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs index 8e3da1ebc..42307ebd2 100644 --- a/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseDecimalTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs @@ -2,12 +2,11 @@ using System.Collections.Generic; using System.Globalization; using System.Numerics; -using ClickHouse.Driver.Tcp.Numerics; namespace ClickHouse.Driver.Tcp.Tests.Numerics; [TestFixture] -public class ClickHouseDecimalTests +public class ClickHouseTcpDecimalTests { [TestCase("0", 0, "0")] [TestCase("123", 0, "123")] @@ -17,7 +16,7 @@ public class ClickHouseDecimalTests [TestCase("-5", 3, "-0.005")] public void ToString_RendersFixedPointInvariant(string mantissa, int scale, string expected) { - var value = new ClickHouseDecimal(BigInteger.Parse(mantissa, CultureInfo.InvariantCulture), scale); + var value = new ClickHouseTcpDecimal(BigInteger.Parse(mantissa, CultureInfo.InvariantCulture), scale); Assert.That(value.ToString(), Is.EqualTo(expected)); } @@ -28,7 +27,7 @@ public void ToString_IsCultureInvariant() try { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); // uses ',' as decimal separator - var value = new ClickHouseDecimal(new BigInteger(12345), 2); + var value = new ClickHouseTcpDecimal(new BigInteger(12345), 2); Assert.That(value.ToString(), Is.EqualTo("123.45")); } finally @@ -45,7 +44,7 @@ public void ToString_IsCultureInvariant() public void FromDecimal_ThenToDecimal_RoundTrips(string text) { decimal original = decimal.Parse(text, CultureInfo.InvariantCulture); - ClickHouseDecimal wide = ClickHouseDecimal.FromDecimal(original); + ClickHouseTcpDecimal wide = ClickHouseTcpDecimal.FromDecimal(original); Assert.That(wide.ToDecimal(), Is.EqualTo(original)); } @@ -53,7 +52,7 @@ public void FromDecimal_ThenToDecimal_RoundTrips(string text) public void ToDecimal_ValueBeyondDecimalRange_ThrowsAndTryReturnsFalse() { // A 20-digit fractional value cannot be a System.Decimal (max scale 28 but this mantissa exceeds 96 bits). - var wide = new ClickHouseDecimal(BigInteger.Pow(10, 39), scale: 0); + var wide = new ClickHouseTcpDecimal(BigInteger.Pow(10, 39), scale: 0); Assert.Multiple(() => { Assert.That(wide.TryToDecimal(out _), Is.False); @@ -64,10 +63,10 @@ public void ToDecimal_ValueBeyondDecimalRange_ThrowsAndTryReturnsFalse() [Test] public void Equals_IsValueBased_IgnoringScaleDifferences() { - var oneScale1 = new ClickHouseDecimal(new BigInteger(10), 1); // 1.0 - var oneScale2 = new ClickHouseDecimal(new BigInteger(100), 2); // 1.00 - var fractionalScale2 = new ClickHouseDecimal(new BigInteger(123), 2); // 1.23 - var fractionalScale3 = new ClickHouseDecimal(new BigInteger(1230), 3); // 1.230 + var oneScale1 = new ClickHouseTcpDecimal(new BigInteger(10), 1); // 1.0 + var oneScale2 = new ClickHouseTcpDecimal(new BigInteger(100), 2); // 1.00 + var fractionalScale2 = new ClickHouseTcpDecimal(new BigInteger(123), 2); // 1.23 + var fractionalScale3 = new ClickHouseTcpDecimal(new BigInteger(1230), 3); // 1.230 Assert.Multiple(() => { @@ -82,11 +81,11 @@ public void Equals_IsValueBased_IgnoringScaleDifferences() [Test] public void GetHashCode_EqualZeroValuesWithDifferentScales_ReturnsSameHashCode() { - var zero = new ClickHouseDecimal(BigInteger.Zero, 0); - var zeroScale1 = new ClickHouseDecimal(BigInteger.Zero, 1); - var zeroScale2 = new ClickHouseDecimal(BigInteger.Zero, 2); - var set = new HashSet { zero }; - var dictionary = new Dictionary { [zero] = "zero" }; + var zero = new ClickHouseTcpDecimal(BigInteger.Zero, 0); + var zeroScale1 = new ClickHouseTcpDecimal(BigInteger.Zero, 1); + var zeroScale2 = new ClickHouseTcpDecimal(BigInteger.Zero, 2); + var set = new HashSet { zero }; + var dictionary = new Dictionary { [zero] = "zero" }; Assert.Multiple(() => { @@ -100,8 +99,8 @@ public void GetHashCode_EqualZeroValuesWithDifferentScales_ReturnsSameHashCode() [Test] public void CompareTo_AlignsScales() { - var half = new ClickHouseDecimal(new BigInteger(5), 1); // 0.5 - var twoThirds = new ClickHouseDecimal(new BigInteger(67), 2); // 0.67 + var half = new ClickHouseTcpDecimal(new BigInteger(5), 1); // 0.5 + var twoThirds = new ClickHouseTcpDecimal(new BigInteger(67), 2); // 0.67 Assert.Multiple(() => { @@ -114,9 +113,9 @@ public void CompareTo_AlignsScales() [Test] public void CompareTo_SameScale_OrdersByMantissa() { - var negative = new ClickHouseDecimal(new BigInteger(-150), 2); // -1.50 - var small = new ClickHouseDecimal(new BigInteger(125), 2); // 1.25 - var large = new ClickHouseDecimal(new BigInteger(200), 2); // 2.00 + var negative = new ClickHouseTcpDecimal(new BigInteger(-150), 2); // -1.50 + var small = new ClickHouseTcpDecimal(new BigInteger(125), 2); // 1.25 + var large = new ClickHouseTcpDecimal(new BigInteger(200), 2); // 2.00 Assert.Multiple(() => { @@ -133,13 +132,13 @@ public void Sign_ReflectsMantissa() { Assert.Multiple(() => { - Assert.That(new ClickHouseDecimal(new BigInteger(-1), 0).Sign, Is.EqualTo(-1)); - Assert.That(new ClickHouseDecimal(BigInteger.Zero, 5).Sign, Is.EqualTo(0)); - Assert.That(new ClickHouseDecimal(new BigInteger(1), 0).Sign, Is.EqualTo(1)); + Assert.That(new ClickHouseTcpDecimal(new BigInteger(-1), 0).Sign, Is.EqualTo(-1)); + Assert.That(new ClickHouseTcpDecimal(BigInteger.Zero, 5).Sign, Is.EqualTo(0)); + Assert.That(new ClickHouseTcpDecimal(new BigInteger(1), 0).Sign, Is.EqualTo(1)); }); } [Test] public void Constructor_NegativeScale_Throws() - => Assert.Throws(() => new ClickHouseDecimal(BigInteger.One, -1)); + => Assert.Throws(() => new ClickHouseTcpDecimal(BigInteger.One, -1)); } diff --git a/ClickHouse.Driver.Tcp.Tests/Numerics/Int256Tests.cs b/ClickHouse.Driver.Tcp.Tests/Numerics/Int256Tests.cs index a9d23a2c0..4e3d16ffd 100644 --- a/ClickHouse.Driver.Tcp.Tests/Numerics/Int256Tests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Numerics/Int256Tests.cs @@ -1,6 +1,5 @@ using System; using System.Numerics; -using ClickHouse.Driver.Tcp.Numerics; namespace ClickHouse.Driver.Tcp.Tests.Numerics; diff --git a/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs b/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs index d8b85ac8a..a904a7714 100644 --- a/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Net; using System.Text; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Parameters; namespace ClickHouse.Driver.Tcp.Tests.Parameters; @@ -514,7 +513,7 @@ public void Infer_Dictionary_MapsToAMapOfTheFirstPairsTypes() [Test] public void Infer_ClickHouseDecimal_KeepsItsOwnScale() { - Assert.That(ParameterTypeInference.Infer(new ClickHouseDecimal(12345, 4), "p"), Is.EqualTo("Decimal128(4)")); + Assert.That(ParameterTypeInference.Infer(new ClickHouseTcpDecimal(12345, 4), "p"), Is.EqualTo("Decimal128(4)")); } [Test] diff --git a/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterTests.cs b/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterTests.cs index 34244643e..371b0f4f4 100644 --- a/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Parameters; namespace ClickHouse.Driver.Tcp.Tests.Parameters; @@ -114,7 +113,7 @@ public void FormatSqlText_WideDecimalString_KeepsEveryDigit() [Test] public void FormatSqlText_ClickHouseDecimal_UsesItsOwnScale() { - var value = new ClickHouseDecimal(12345, 4); + var value = new ClickHouseTcpDecimal(12345, 4); Assert.That(TcpParameterFormatter.FormatSqlText(value, "Decimal64(4)", "p"), Is.EqualTo("1.2345")); } diff --git a/ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseBinaryReaderWriterTests.cs b/ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseBinaryReaderWriterTests.cs index 6fcda0e02..29c5074f1 100644 --- a/ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseBinaryReaderWriterTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseBinaryReaderWriterTests.cs @@ -3,7 +3,6 @@ using System.Numerics; using System.Threading; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Protocol; using ClickHouse.Driver.Tcp.Tests.Utilities; diff --git a/ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpColumnTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpColumnTests.cs new file mode 100644 index 000000000..a6523b7a0 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpColumnTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClickHouse.Driver.Tcp.Tests.Types; + +// The round-trip behaviour lives in PublicSurfaceIntegrationTests. This covers what an insert cannot show: +// the shape of the column the factory hands back, and that it takes over the caller's array rather than +// copying it. +[TestFixture] +public class ClickHouseTcpColumnTests +{ + [Test] + public void Create_FromArray_ReportsNameRowCountAndNoTypeName() + { + IColumn column = ClickHouseTcpColumn.Create("id", new[] { 1, 2, 3 }); + + Assert.Multiple(() => + { + Assert.That(column.Name, Is.EqualTo("id")); + Assert.That(column.RowCount, Is.EqualTo(3)); + + // No header to take one from: the insert resolves the type from the target's schema. + Assert.That(column.TypeName, Is.Null); + Assert.That(column.ElementType, Is.EqualTo(typeof(int))); + }); + } + + [Test] + public void Create_FromArray_TakesOverTheArrayWithoutCopying() + { + var values = new[] { 1, 2, 3 }; + IColumn column = ClickHouseTcpColumn.Create("id", values); + + values[0] = 99; + + Assert.That(column.Values[0], Is.EqualTo(99)); + } + + [Test] + public void Create_FromAList_DoesNotAliasTheList() + { + // A List is not a T[], so it is copied; mutating the list afterwards must not reach the column. + var values = new List { 1, 2, 3 }; + IColumn column = ClickHouseTcpColumn.Create("id", values); + + values[0] = 99; + + Assert.That(column.Values[0], Is.EqualTo(1)); + } + + [Test] + public void Create_FromASequenceThatIsAlreadyAnArray_AvoidsTheCopy() + { + var values = new[] { 1, 2, 3 }; + IColumn column = ClickHouseTcpColumn.Create("id", values.AsEnumerable()); + + values[0] = 99; + + Assert.That(column.Values[0], Is.EqualTo(99)); + } + + [Test] + public void Create_EmptyArray_IsAZeroRowColumn() + { + IColumn column = ClickHouseTcpColumn.Create("name", Array.Empty()); + + Assert.Multiple(() => + { + Assert.That(column.RowCount, Is.EqualTo(0)); + Assert.That(column.Values.Length, Is.EqualTo(0)); + }); + } + + [Test] + public void Create_JaggedRows_HasTheArrayAsItsElementType() + { + IColumn column = ClickHouseTcpColumn.Create("tags", new[] { new uint[] { 1, 2 }, Array.Empty() }); + + Assert.Multiple(() => + { + Assert.That(column.RowCount, Is.EqualTo(2)); + Assert.That(column.ElementType, Is.EqualTo(typeof(uint[]))); + Assert.That(column.GetValue(0), Is.EqualTo(new uint[] { 1, 2 })); + }); + } + + [Test] + public void Create_NullArguments_Throw() + { + Assert.Multiple(() => + { + Assert.Throws(() => ClickHouseTcpColumn.Create(null, new[] { 1 })); + Assert.Throws(() => ClickHouseTcpColumn.Create("id", (int[])null)); + Assert.Throws(() => ClickHouseTcpColumn.Create("id", (IEnumerable)null)); + }); + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Types/DecimalColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/DecimalColumnCodecTests.cs index 92d511a95..7e4568b87 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/DecimalColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/DecimalColumnCodecTests.cs @@ -1,7 +1,6 @@ using System; using System.Numerics; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types; using ClickHouse.Driver.Tcp.Types.Codecs; using static ClickHouse.Driver.Tcp.Tests.Utilities.CodecTestHarness; @@ -24,7 +23,7 @@ public async Task WriteColumn_BackingWidthMatchesPrecision(string type, int byte IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); IColumn column = bytesPerValue <= 8 ? new ArrayColumn("c", type, new[] { 1m }) - : new ArrayColumn("c", type, new[] { new ClickHouseDecimal(BigInteger.One, 0) }); + : new ArrayColumn("c", type, new[] { new ClickHouseTcpDecimal(BigInteger.One, 0) }); byte[] bytes = await WriteAsync(w => codec.WriteColumn(w, column)); @@ -62,10 +61,10 @@ public async Task WriteColumn_NegativeWideDecimal_SignExtendsAcrossFullWidth() // InsertRoundTripCase. Two's complement: -2^200 is 2^256 - 2^200 = (2^56 - 1) << 200, so every bit from // 200 up is set, i.e. bytes 0..24 are zero and bytes 25..31 are 0xFF in the little-endian 32-byte limb. const string type = "Decimal(76, 0)"; - var value = new ClickHouseDecimal(-BigInteger.Pow(2, 200), 0); + var value = new ClickHouseTcpDecimal(-BigInteger.Pow(2, 200), 0); IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); - byte[] bytes = await WriteAsync(w => codec.WriteColumn(w, new ArrayColumn("c", type, new[] { value }))); + byte[] bytes = await WriteAsync(w => codec.WriteColumn(w, new ArrayColumn("c", type, new[] { value }))); var expected = new byte[32]; expected.AsSpan(25).Fill(0xFF); @@ -114,8 +113,8 @@ public async Task WriteColumn_WideValueDownscaledToPrecisionBoundary_WritesValue const string type = "Decimal(19, 2)"; IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); BigInteger boundary = BigInteger.Pow(10, 19) - BigInteger.One; - var value = new ClickHouseDecimal(boundary * 10, 3); - var column = new ArrayColumn("c", type, new[] { value }); + var value = new ClickHouseTcpDecimal(boundary * 10, 3); + var column = new ArrayColumn("c", type, new[] { value }); byte[] bytes = await WriteAsync(w => codec.WriteColumn(w, column)); @@ -127,8 +126,8 @@ public void WriteColumn_WideValueDownscaledBeyondDeclaredPrecision_ThrowsOverflo { const string type = "Decimal(19, 2)"; IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); - var value = new ClickHouseDecimal(BigInteger.Pow(10, 20), 3); - var column = new ArrayColumn("c", type, new[] { value }); + var value = new ClickHouseTcpDecimal(BigInteger.Pow(10, 20), 3); + var column = new ArrayColumn("c", type, new[] { value }); Assert.ThrowsAsync(async () => await WriteAsync(w => codec.WriteColumn(w, column))); } @@ -138,8 +137,8 @@ public void WriteColumn_WideValueCannotBeDownscaledExactly_ThrowsArgumentExcepti { const string type = "Decimal(19, 2)"; IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); - var value = new ClickHouseDecimal(BigInteger.One, 3); - var column = new ArrayColumn("c", type, new[] { value }); + var value = new ClickHouseTcpDecimal(BigInteger.One, 3); + var column = new ArrayColumn("c", type, new[] { value }); Assert.ThrowsAsync(async () => await WriteAsync(w => codec.WriteColumn(w, column))); } @@ -151,8 +150,8 @@ public void WriteColumn_WideValueExceedsDeclaredPrecision_ThrowsOverflowExceptio { IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); BigInteger mantissa = BigInteger.Pow(10, precision); - var positive = new ArrayColumn("c", type, new[] { new ClickHouseDecimal(mantissa, 0) }); - var negative = new ArrayColumn("c", type, new[] { new ClickHouseDecimal(-mantissa, 0) }); + var positive = new ArrayColumn("c", type, new[] { new ClickHouseTcpDecimal(mantissa, 0) }); + var negative = new ArrayColumn("c", type, new[] { new ClickHouseTcpDecimal(-mantissa, 0) }); Assert.Multiple(() => { @@ -182,10 +181,10 @@ public async Task WriteColumn_WideValueAtDeclaredPrecisionBoundary_WritesValue(s { IColumnCodec codec = DecimalColumnCodec.Create(TypeParser.Parse(type)); BigInteger boundary = BigInteger.Pow(10, precision) - BigInteger.One; - var column = new ArrayColumn( + var column = new ArrayColumn( "c", type, - new[] { new ClickHouseDecimal(boundary, 0), new ClickHouseDecimal(-boundary, 0) }); + new[] { new ClickHouseTcpDecimal(boundary, 0), new ClickHouseTcpDecimal(-boundary, 0) }); byte[] bytes = await WriteAsync(w => codec.WriteColumn(w, column)); @@ -201,8 +200,8 @@ public void CanWrite_MatchesValueType() Assert.Multiple(() => { Assert.That(small.CanWrite(new ArrayColumn("c", "Decimal(9, 2)", Array.Empty())), Is.True); - Assert.That(small.CanWrite(new ArrayColumn("c", "Decimal(9, 2)", Array.Empty())), Is.False); - Assert.That(wide.CanWrite(new ArrayColumn("c", "Decimal(38, 2)", Array.Empty())), Is.True); + Assert.That(small.CanWrite(new ArrayColumn("c", "Decimal(9, 2)", Array.Empty())), Is.False); + Assert.That(wide.CanWrite(new ArrayColumn("c", "Decimal(38, 2)", Array.Empty())), Is.True); Assert.That(wide.CanWrite(new ArrayColumn("c", "Decimal(38, 2)", Array.Empty())), Is.False); }); } diff --git a/ClickHouse.Driver.Tcp.Tests/Types/DynamicTypeInferenceTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/DynamicTypeInferenceTests.cs index f070cbba0..1f15bdcc2 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/DynamicTypeInferenceTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/DynamicTypeInferenceTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types.Codecs; namespace ClickHouse.Driver.Tcp.Tests.Types; @@ -78,12 +77,12 @@ public void Infer_Decimal_MapsToDecimal128AtItsScaleAndCoerces() { (string typeName, object canonical) = DynamicTypeInference.Infer(12.340m); Assert.That(typeName, Is.EqualTo("Decimal(38, 3)")); - Assert.That(canonical, Is.InstanceOf()); + Assert.That(canonical, Is.InstanceOf()); } [Test] public void Infer_ClickHouseDecimal_MapsToDecimal256AtItsScale() - => Assert.That(DynamicTypeInference.Infer(new ClickHouseDecimal(new System.Numerics.BigInteger(12345), 2)).TypeName, Is.EqualTo("Decimal(76, 2)")); + => Assert.That(DynamicTypeInference.Infer(new ClickHouseTcpDecimal(new System.Numerics.BigInteger(12345), 2)).TypeName, Is.EqualTo("Decimal(76, 2)")); [Test] public void Infer_Array_RecursesIntoElementType() @@ -98,11 +97,11 @@ public void Infer_Map_MapsToMapOfKeyAndValue() => Assert.That(DynamicTypeInference.Infer(new[] { new KeyValuePair("a", 1) }).TypeName, Is.EqualTo("Map(String, UInt32)")); // A Map key or value whose ClickHouse type only its value can settle — an IPAddress's family, a - // ClickHouseDecimal's scale — resolves from the pairs, the same way an Array element or a Tuple element does. + // ClickHouseTcpDecimal's scale — resolves from the pairs, the same way an Array element or a Tuple element does. [Test] public void Infer_MapWithValueDisambiguatedKeyAndValue_ReadsThePairs() => Assert.That( - DynamicTypeInference.Infer(new[] { new KeyValuePair(IPAddress.Parse("::1"), new ClickHouseDecimal(new System.Numerics.BigInteger(12345), 2)) }).TypeName, + DynamicTypeInference.Infer(new[] { new KeyValuePair(IPAddress.Parse("::1"), new ClickHouseTcpDecimal(new System.Numerics.BigInteger(12345), 2)) }).TypeName, Is.EqualTo("Map(IPv6, Decimal(76, 2))")); [Test] diff --git a/ClickHouse.Driver.Tcp.Tests/Types/FixedWidthColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/FixedWidthColumnCodecTests.cs index 792b4481f..67b6ded15 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/FixedWidthColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/FixedWidthColumnCodecTests.cs @@ -3,7 +3,6 @@ using System.Numerics; using System.Threading; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Protocol; using ClickHouse.Driver.Tcp.Types; using ClickHouse.Driver.Tcp.Types.Codecs; diff --git a/ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs index 978b7c6e0..bdb63b79e 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Protocol; using ClickHouse.Driver.Tcp.Tests.Utilities; using ClickHouse.Driver.Tcp.Types; diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs index ee1b56696..d35944dee 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types; namespace ClickHouse.Driver.Tcp.Tests.Utilities; @@ -141,7 +140,7 @@ public static IEnumerable Cases() yield return IpAddresses("IPv4", "0.0.0.0", "127.0.0.1", "192.168.1.1", "255.255.255.255"); yield return IpAddresses("IPv6", "::", "::1", "2001:db8::1", "fe80::1"); - // Decimal32/64 surface as System.Decimal; Decimal128/256 as ClickHouseDecimal. + // Decimal32/64 surface as System.Decimal; Decimal128/256 as ClickHouseTcpDecimal. yield return Decimals("Decimal(9, 2)", 0m, 1.23m, -1.23m, 9999999.99m); yield return Decimals("Decimal(18, 4)", 0m, 12345.6789m, -12345.6789m, 99999999999999.9999m); yield return WideDecimals("Decimal(38, 10)", "0", "12345.6789", "-98765.4321"); @@ -309,8 +308,8 @@ public static IEnumerable Cases() yield return Arrays("Decimal(9, 2)", new[] { 0m, 1.23m, -1.23m, 9999999.99m }, Array.Empty()); yield return Arrays("Decimal(18, 4)", new[] { 12345.6789m, -12345.6789m }); - yield return Arrays("Decimal(38, 10)", new[] { ParseWide("12345.6789"), ParseWide("-98765.4321") }); - yield return Arrays("Decimal(76, 20)", new[] { ParseWide("1.00000000000000000001"), ParseWide("-1.00000000000000000001") }); + yield return Arrays("Decimal(38, 10)", new[] { ParseWide("12345.6789"), ParseWide("-98765.4321") }); + yield return Arrays("Decimal(76, 20)", new[] { ParseWide("1.00000000000000000001"), ParseWide("-1.00000000000000000001") }); yield return Arrays("IntervalSecond", new[] { 0L, 1L, -5L }, Array.Empty()); yield return Arrays("IntervalDay", new[] { 7L, -30L }); @@ -449,11 +448,11 @@ public static IEnumerable Cases() (IPAddress.Parse("255.255.255.255"), IPAddress.Parse("2001:db8::1")), })); - // Decimal32/64 surface as System.Decimal, Decimal128/256 as ClickHouseDecimal — one tuple spans all four. + // Decimal32/64 surface as System.Decimal, Decimal128/256 as ClickHouseTcpDecimal — one tuple spans all four. yield return Same( "Tuple(Decimal(9, 2), Decimal(18, 4), Decimal(38, 10), Decimal(76, 20))", "Tuple(Decimal(9, 2), Decimal(18, 4), Decimal(38, 10), Decimal(76, 20))", - name => new TupleColumn(name, "Tuple(Decimal(9, 2), Decimal(18, 4), Decimal(38, 10), Decimal(76, 20))", new (decimal, decimal, ClickHouseDecimal, ClickHouseDecimal)[] + name => new TupleColumn(name, "Tuple(Decimal(9, 2), Decimal(18, 4), Decimal(38, 10), Decimal(76, 20))", new (decimal, decimal, ClickHouseTcpDecimal, ClickHouseTcpDecimal)[] { (0m, 0m, ParseWide("0"), ParseWide("0")), (1.23m, 12345.6789m, ParseWide("12345.6789"), ParseWide("1.00000000000000000001")), @@ -1030,14 +1029,14 @@ public static IEnumerable Cases() DynamicSettings); // A map whose key and value types only the pair values can settle: an IPAddress picks IPv4 or IPv6 by its - // address family, and a ClickHouseDecimal carries its own scale. Inferring from the CLR type alone cannot + // address family, and a ClickHouseTcpDecimal carries its own scale. Inferring from the CLR type alone cannot // reach either, so this covers the Map slots the Array and Tuple cases above already cover. yield return Same( "Dynamic [map value, value-disambiguated key and value]", "Dynamic", name => new ArrayColumn(name, "Dynamic", new object[] { - Pairs((IPAddress.Parse("10.0.0.1"), ParseWide("12345.6789")), (IPAddress.Parse("10.0.0.2"), ParseWide("-1.0002"))), + Pairs((IPAddress.Parse("10.0.0.1"), ParseWide("12345.6789")), (IPAddress.Parse("10.0.0.2"), ParseWide("-1.0002"))), null, }), DynamicSettings); @@ -1050,7 +1049,7 @@ public static IEnumerable Cases() // One Dynamic column holding a value of (basically) every supported type — each row's runtime CLR type is // inferred to a distinct ClickHouse type, so the block's type list spans them all at once. Uses the - // canonical read-back CLR types (e.g. ClickHouseDecimal) so insert equals read-back; the + // canonical read-back CLR types (e.g. ClickHouseTcpDecimal) so insert equals read-back; the // DateTimeOffset/DateTime/decimal inputs, whose read-back type differs, are covered separately below. yield return Same( "Dynamic [every type + composites]", @@ -1063,7 +1062,7 @@ public static IEnumerable Cases() Int256.FromBigInteger(-System.Numerics.BigInteger.Pow(2, 200)), 1.5f, 3.5d, true, "héllo✓", new Guid("00112233-4455-6677-8899-aabbccddeeff"), new DateOnly(2024, 1, 15), IPAddress.Parse("192.168.1.1"), IPAddress.Parse("2001:db8::1"), - new ClickHouseDecimal(System.Numerics.BigInteger.Parse("1234567890123456789012345"), 5), + new ClickHouseTcpDecimal(System.Numerics.BigInteger.Parse("1234567890123456789012345"), 5), new ulong[] { 1, 2, 3 }, Pairs(("k", 9), ("m", 10)), (1, "t"), @@ -1073,7 +1072,7 @@ public static IEnumerable Cases() // Inputs whose inferred ClickHouse type reads back as a different (canonical) CLR type: a DateTimeOffset // and a DateTime infer to DateTime64(9) (read back as the raw long nanosecond count), and a System.Decimal - // infers to Decimal128 (read back as ClickHouseDecimal, equal by value). + // infers to Decimal128 (read back as ClickHouseTcpDecimal, equal by value). yield return new InsertRoundTripCase( "Dynamic [datetime + decimal inference]", "Dynamic", @@ -1088,7 +1087,7 @@ public static IEnumerable Cases() { (new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(5)).UtcDateTime.Ticks - DateTime.UnixEpoch.Ticks) * 100, (new DateTime(1988, 8, 28, 11, 22, 33, DateTimeKind.Utc).Ticks - DateTime.UnixEpoch.Ticks) * 100, - new ClickHouseDecimal(new System.Numerics.BigInteger(123456789), 4), + new ClickHouseTcpDecimal(new System.Numerics.BigInteger(123456789), 4), null, }), DynamicSettings); @@ -1625,12 +1624,12 @@ private static InsertRoundTripCase NullableDateTime64s(int scale, params long?[] return Same($"{type} [{counts.Length} rows]", type, name => new ArrayColumn(name, type, counts)); } - // Nullable of a wide decimal (Decimal128/256) surfaces a ClickHouseDecimal?; a null string maps to a null row. + // Nullable of a wide decimal (Decimal128/256) surfaces a ClickHouseTcpDecimal?; a null string maps to a null row. private static InsertRoundTripCase NullableWideDecimals(string innerType, params string[] values) { string type = $"Nullable({innerType})"; - return Same($"{type} [{values.Length} rows]", type, name => new ArrayColumn( - name, type, values.Select(v => v is null ? (ClickHouseDecimal?)null : ParseWide(v)).ToArray())); + return Same($"{type} [{values.Length} rows]", type, name => new ArrayColumn( + name, type, values.Select(v => v is null ? (ClickHouseTcpDecimal?)null : ParseWide(v)).ToArray())); } private static InsertRoundTripCase NullableStrings(params string[] values) @@ -1713,16 +1712,16 @@ private static InsertRoundTripCase Decimals(string clickHouseType, params decima => Same($"{clickHouseType} [{values.Length} rows]", clickHouseType, name => new ArrayColumn(name, clickHouseType, values)); private static InsertRoundTripCase WideDecimals(string clickHouseType, params string[] values) - => Same($"{clickHouseType} [{values.Length} rows]", clickHouseType, name => new ArrayColumn(name, clickHouseType, Array.ConvertAll(values, ParseWide))); + => Same($"{clickHouseType} [{values.Length} rows]", clickHouseType, name => new ArrayColumn(name, clickHouseType, Array.ConvertAll(values, ParseWide))); - private static ClickHouseDecimal ParseWide(string text) + private static ClickHouseTcpDecimal ParseWide(string text) { bool negative = text.StartsWith('-'); string digits = negative ? text.Substring(1) : text; int dot = digits.IndexOf('.'); int scale = dot < 0 ? 0 : digits.Length - dot - 1; System.Numerics.BigInteger mantissa = System.Numerics.BigInteger.Parse(dot < 0 ? digits : digits.Remove(dot, 1), System.Globalization.CultureInfo.InvariantCulture); - return new ClickHouseDecimal(negative ? -mantissa : mantissa, scale); + return new ClickHouseTcpDecimal(negative ? -mantissa : mantissa, scale); } /// A case that inserts and reads back the same column — the common shape. diff --git a/ClickHouse.Driver.Tcp/.editorconfig b/ClickHouse.Driver.Tcp/.editorconfig new file mode 100644 index 000000000..d557f2bed --- /dev/null +++ b/ClickHouse.Driver.Tcp/.editorconfig @@ -0,0 +1,5 @@ +[*.cs] +dotnet_diagnostic.RS0016.severity = error # RS0016: Public APIs must be declared +dotnet_diagnostic.RS0017.severity = error # RS0017: Remove deleted types and members from the declared API +dotnet_diagnostic.RS0024.severity = error # RS0024: The contents of the public API files are invalid +dotnet_diagnostic.RS0048.severity = error # RS0048: A missing API file turns the checks above off silently diff --git a/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj b/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj index 97e1dff77..f7c5ee109 100644 --- a/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj +++ b/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj @@ -9,13 +9,28 @@ Recommended true latest - + $(NoWarn);SA0001 false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs index 025b741c8..89d8af426 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs @@ -78,8 +78,10 @@ public sealed class ClickHouseTcpClient : IClickHouseTcpClient /// The client configuration (endpoint, credentials, timeouts, client-level settings). /// is null. /// An option value is invalid (see ). + /// The host is big-endian. public ClickHouseTcpClient(ClickHouseTcpClientOptions options) { + HostEndianness.RequireLittleEndian(); ArgumentNullException.ThrowIfNull(options); options.Validate(); Options = options.WithOwnedCustomSettings(); @@ -363,6 +365,26 @@ public async ValueTask ExecuteAsync( } } + /// + public async ValueTask ExecuteScalarAsync( + string sql, + ClickHouseTcpQueryOptions options = null, + CancellationToken cancellationToken = default) + { + await foreach (Block block in StreamAsync(sql, options, cancellationToken).ConfigureAwait(false)) + { + if (block.RowCount == 0 || block.ColumnCount == 0) + { + continue; + } + + // Read before returning: the block is borrowed and StreamAsync disposes it as the loop unwinds. + return block[0].GetValue(0); + } + + return null; + } + /// /// Inserts columnar data. The columns are matched to the target's schema by name (order is free, and /// a named subset inserts only those columns, the server filling the rest from their defaults); values are @@ -579,6 +601,23 @@ public async ValueTask PingAsync(CancellationToken cancellationToken = default) } } + /// + public async ValueTask GetServerInfoAsync(CancellationToken cancellationToken = default) + { + await using IConnectionLease lease = await source.RentAsync(cancellationToken).ConfigureAwait(false); + ServerHandshake server = lease.Connection.Server; + return new ClickHouseTcpServerInfo + { + Name = server.ServerName, + VersionMajor = server.VersionMajor, + VersionMinor = server.VersionMinor, + VersionPatch = server.VersionPatch, + ProtocolRevision = server.Negotiated.Version, + Timezone = server.Timezone, + DisplayName = server.DisplayName, + }; + } + /// public ValueTask DisposeAsync() => source.DisposeAsync(); diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs new file mode 100644 index 000000000..49f6d39dc --- /dev/null +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace ClickHouse.Driver.Tcp; + +/// +/// Owns one and its connection pool, and hands out views onto it that cannot +/// close it. Register this as a singleton in a dependency-injection container and let it be the thing that gets +/// disposed at shutdown; inject 's result everywhere else. +/// +/// +/// +/// A is already thread-safe and pooled, so this adds no pooling of its own. What +/// it adds is ownership: returns a client whose DisposeAsync does nothing, so a +/// scoped service that disposes what it was injected cannot take the shared pool down with it. Disposing the data +/// source closes the pool, once. +/// +/// +/// This type is experimental: its surface may change in a future release. Suppress diagnostic +/// CHTCP0001 to acknowledge that. +/// +/// +[Experimental("CHTCP0001")] +public sealed class ClickHouseTcpDataSource : IAsyncDisposable, IDisposable +{ + private readonly ClickHouseTcpClient client; + private readonly NonOwningClient view; + + /// Creates a data source from a connection string. + /// The connection string (keys such as Host, Port, Username, set_<name>). + /// is null. + /// A resulting option value is invalid. + public ClickHouseTcpDataSource(string connectionString) + : this(ClickHouseTcpClientOptions.FromConnectionString(connectionString)) + { + } + + /// Creates a data source from options. + /// The client configuration (endpoint, credentials, timeouts, client-level settings). + /// is null. + /// An option value is invalid (see ). + public ClickHouseTcpDataSource(ClickHouseTcpClientOptions options) + { + client = new ClickHouseTcpClient(options); + view = new NonOwningClient(client); + } + + /// The configuration every operation from this data source runs under. + public ClickHouseTcpClientOptions Options => client.Options; + + /// + /// Returns the shared client. The same instance every time, and disposing it does nothing — only disposing + /// the data source closes the pool. + /// + /// A non-owning view of the shared client. + public IClickHouseTcpClient GetClient() => view; + + /// + /// Opens a session on the shared pool: one connection, held until the session is disposed, that carries + /// server-side state such as a temporary table or a SET from one operation to the next. + /// + /// Unlike , a session is the caller's to dispose, and holds one of the + /// pool's connections until it is. + /// A token to observe while waiting for and establishing the connection. + /// A session pinned to one connection. + public ValueTask OpenSessionAsync(CancellationToken cancellationToken = default) + => client.OpenSessionAsync(cancellationToken); + + /// Closes the pool and every connection in it. Views handed out by stop working. + /// A task that completes when the pool is closed. + public ValueTask DisposeAsync() => client.DisposeAsync(); + + /// + /// Closes the pool, blocking until it is closed. Present because a synchronous + /// ServiceProvider.Dispose() rejects a singleton that offers only ; + /// prefer wherever the call site can await. + /// + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + /// Delegates every operation to the owned client and swallows disposal, so an injected consumer cannot close + /// a pool it does not own. + /// + private sealed class NonOwningClient(ClickHouseTcpClient inner) : IClickHouseTcpClient + { + public ClickHouseTcpClientOptions Options => inner.Options; + + public IAsyncEnumerable StreamAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) + => inner.StreamAsync(sql, options, cancellationToken); + + public IAsyncEnumerable QueryAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) + => inner.QueryAsync(sql, options, cancellationToken); + + public IAsyncEnumerable QueryAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) + where T : class + => inner.QueryAsync(sql, options, cancellationToken); + + public ValueTask ExecuteAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) + => inner.ExecuteAsync(sql, options, cancellationToken); + + public ValueTask ExecuteScalarAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) + => inner.ExecuteScalarAsync(sql, options, cancellationToken); + + public ValueTask InsertAsync(string sql, IReadOnlyList columns, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) + => inner.InsertAsync(sql, columns, options, cancellationToken); + + public ValueTask InsertRowsAsync(string sql, IReadOnlyList rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) + where T : class + => inner.InsertRowsAsync(sql, rows, options, cancellationToken); + + public ValueTask InsertRowsAsync(string sql, IReadOnlyList rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) + => inner.InsertRowsAsync(sql, rows, options, cancellationToken); + + public ValueTask PingAsync(CancellationToken cancellationToken = default) + => inner.PingAsync(cancellationToken); + + public ValueTask GetServerInfoAsync(CancellationToken cancellationToken = default) + => inner.GetServerInfoAsync(cancellationToken); + + public ValueTask OpenSessionAsync(CancellationToken cancellationToken = default) + => inner.OpenSessionAsync(cancellationToken); + + /// Does nothing: the data source owns the client. + /// A completed task. + public ValueTask DisposeAsync() => default; + } +} diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs new file mode 100644 index 000000000..78a1390b2 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs @@ -0,0 +1,47 @@ +using System; + +namespace ClickHouse.Driver.Tcp; + +/// +/// What the server said about itself in its ServerHello: its version, the protocol revision in use, and the +/// session defaults the client resolves timestamps against. Read it with +/// . +/// +public sealed record ClickHouseTcpServerInfo +{ + /// The server identifier, normally "ClickHouse". + public string Name { get; init; } + + /// The server's major version. + public int VersionMajor { get; init; } + + /// The server's minor version. + public int VersionMinor { get; init; } + + /// The server's patch version. + public int VersionPatch { get; init; } + + /// + /// The protocol revision in use for this connection: the lower of what the client and the server support, so + /// it can be below what either alone offers. Feature gates are decided against this number. + /// + public int ProtocolRevision { get; init; } + + /// + /// The server's timezone (e.g. "UTC"), which is what a bare DateTime column is interpreted in. + /// Empty when the server did not send one. + /// + public string Timezone { get; init; } = string.Empty; + + /// The server's configured display name, or empty when it sent none. + public string DisplayName { get; init; } = string.Empty; + + /// + /// The server version as a , for comparing against a required version. + /// + public Version Version => new(VersionMajor, VersionMinor, VersionPatch); + + /// Renders the server name and version, e.g. "ClickHouse 25.8.1". + /// The name followed by the version. + public override string ToString() => $"{Name} {Version.ToString(3)}"; +} diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpSession.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpSession.cs index 21f3415f9..8dc210db8 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpSession.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpSession.cs @@ -96,6 +96,17 @@ public ValueTask InsertRowsAsync( public ValueTask PingAsync(CancellationToken cancellationToken = default) => operations.PingAsync(cancellationToken); + /// + public ValueTask GetServerInfoAsync(CancellationToken cancellationToken = default) + => operations.GetServerInfoAsync(cancellationToken); + + /// + public ValueTask ExecuteScalarAsync( + string sql, + ClickHouseTcpQueryOptions options = null, + CancellationToken cancellationToken = default) + => operations.ExecuteScalarAsync(sql, options, cancellationToken); + /// /// Ends the session, closing its connection rather than pooling it. The client the session came from is /// unaffected and keeps working. diff --git a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs index b5c3d5ff7..726fea645 100644 --- a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs +++ b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs @@ -103,6 +103,32 @@ ValueTask ExecuteAsync( ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default); + /// + /// Runs a query and returns the first column of its first row, boxed — the one-value counterpart of + /// , for a count(), a + /// version() or an EXISTS. + /// + /// + /// The returned value is owned and safe to retain. Rows after the first are not read: the result is abandoned + /// as soon as the value is in hand, which tells the server to stop producing it, so this is cheap even against + /// a query that would return many rows. A NULL in that cell comes back as null, the same as no rows at + /// all — select a non-nullable expression if the two need telling apart. + /// + /// + /// Because the result is abandoned, the query's activity ends with no status set, the same as any other + /// enumeration stopped early. Treat a statusless span from this call as a success, not a failure. + /// + /// + /// The SQL text. + /// Per-query options (query id, settings, parameters), or null for the client defaults. + /// A token to observe for cancellation. + /// The first column of the first row, or null when the result has no rows. + /// is null. + ValueTask ExecuteScalarAsync( + string sql, + ClickHouseTcpQueryOptions options = null, + CancellationToken cancellationToken = default); + /// /// Inserts columnar data. The columns are matched to the target's schema by name (order is free, and a /// named subset inserts only those columns, the server filling the rest from their defaults); values are @@ -184,4 +210,18 @@ ValueTask InsertRowsAsync( /// A token to observe for cancellation. /// A task that completes when the server answers. ValueTask PingAsync(CancellationToken cancellationToken = default); + + /// + /// Reports what the server said about itself when the connection was handshaken: version, protocol revision + /// and timezone. Use it to branch on a server version rather than parsing SELECT version(). + /// + /// + /// Reads the handshake of a connection rather than querying the server, so it costs no round trip once one is + /// open — though it opens one if the pool is empty. A pooled client answers from whichever connection it + /// rents; the protocol revision is negotiated per connection, so read it from a + /// if you need the number a specific later operation will run under. + /// + /// A token to observe for cancellation. + /// The server's identity and the negotiated protocol revision. + ValueTask GetServerInfoAsync(CancellationToken cancellationToken = default); } diff --git a/ClickHouse.Driver.Tcp/Format/Block.cs b/ClickHouse.Driver.Tcp/Format/Block.cs index 229e6cd23..e64564c9e 100644 --- a/ClickHouse.Driver.Tcp/Format/Block.cs +++ b/ClickHouse.Driver.Tcp/Format/Block.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; using System.Threading; +using ClickHouse.Driver.Tcp.Format; using ClickHouse.Driver.Tcp.Types; -namespace ClickHouse.Driver.Tcp.Format; +namespace ClickHouse.Driver.Tcp; /// /// A decoded block: the columnar unit exchanged for Data, Totals, Extremes, Log, and ProfileEvents. Carries an diff --git a/ClickHouse.Driver.Tcp/Numerics/ClickHouseDecimal.cs b/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs similarity index 64% rename from ClickHouse.Driver.Tcp/Numerics/ClickHouseDecimal.cs rename to ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs index 29743fe3f..156becab4 100644 --- a/ClickHouse.Driver.Tcp/Numerics/ClickHouseDecimal.cs +++ b/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Numerics; -namespace ClickHouse.Driver.Tcp.Numerics; +namespace ClickHouse.Driver.Tcp; /// /// A fixed-point decimal whose unscaled value (mantissa) is a signed 256-bit integer with an associated scale @@ -13,7 +13,7 @@ namespace ClickHouse.Driver.Tcp.Numerics; /// Equality and comparison are value-based: 1.0 and 1.00 compare equal despite different scales. /// /// -public readonly struct ClickHouseDecimal : IEquatable, IComparable, IFormattable +public readonly struct ClickHouseTcpDecimal : IEquatable, IComparable, IFormattable { private readonly Int256 mantissa; private readonly int scale; @@ -22,7 +22,7 @@ namespace ClickHouse.Driver.Tcp.Numerics; /// The unscaled value. /// The number of fractional digits; must be non-negative. /// is negative. - public ClickHouseDecimal(Int256 mantissa, int scale) + public ClickHouseTcpDecimal(Int256 mantissa, int scale) { if (scale < 0) { @@ -36,7 +36,7 @@ public ClickHouseDecimal(Int256 mantissa, int scale) /// Initializes a value from a 128-bit mantissa (sign-extended to 256 bits) and a scale. /// The unscaled value. /// The number of fractional digits; must be non-negative. - public ClickHouseDecimal(Int128 mantissa, int scale) + public ClickHouseTcpDecimal(Int128 mantissa, int scale) : this(Int256.FromBigInteger(mantissa), scale) { } @@ -44,7 +44,7 @@ public ClickHouseDecimal(Int128 mantissa, int scale) /// Initializes a value from an arbitrary-precision mantissa and a scale. /// The unscaled value; must fit in a signed 256-bit integer. /// The number of fractional digits; must be non-negative. - public ClickHouseDecimal(BigInteger mantissa, int scale) + public ClickHouseTcpDecimal(BigInteger mantissa, int scale) : this(Int256.FromBigInteger(mantissa), scale) { } @@ -60,8 +60,8 @@ public ClickHouseDecimal(BigInteger mantissa, int scale) /// Builds a value from a , preserving its scale exactly. /// The value to convert. - /// The equivalent . - public static ClickHouseDecimal FromDecimal(decimal value) + /// The equivalent . + public static ClickHouseTcpDecimal FromDecimal(decimal value) { int[] bits = decimal.GetBits(value); int decimalScale = (bits[3] >> 16) & 0xFF; @@ -69,7 +69,7 @@ public static ClickHouseDecimal FromDecimal(decimal value) BigInteger magnitude = ((BigInteger)(uint)bits[2] << 64) | ((BigInteger)(uint)bits[1] << 32) | (uint)bits[0]; BigInteger mantissa = negative ? -magnitude : magnitude; - return new ClickHouseDecimal(mantissa, decimalScale); + return new ClickHouseTcpDecimal(mantissa, decimalScale); } /// Converts this value to a . @@ -120,13 +120,13 @@ public bool TryToDecimal(out decimal value) } /// - public bool Equals(ClickHouseDecimal other) => CompareTo(other) == 0; + public bool Equals(ClickHouseTcpDecimal other) => CompareTo(other) == 0; /// - public override bool Equals(object obj) => obj is ClickHouseDecimal other && Equals(other); + public override bool Equals(object obj) => obj is ClickHouseTcpDecimal other && Equals(other); /// - public int CompareTo(ClickHouseDecimal other) + public int CompareTo(ClickHouseTcpDecimal other) { // Equal-scale is the common case and stays off BigInteger: the mantissas order directly. if (scale == other.scale) @@ -203,19 +203,50 @@ public string ToString(string format, IFormatProvider formatProvider) return string.Concat(sign.AsSpan(), digits.AsSpan(0, pointIndex), ".".AsSpan(), digits.AsSpan(pointIndex)); } - public static bool operator ==(ClickHouseDecimal left, ClickHouseDecimal right) => left.Equals(right); - - public static bool operator !=(ClickHouseDecimal left, ClickHouseDecimal right) => !left.Equals(right); - - public static bool operator <(ClickHouseDecimal left, ClickHouseDecimal right) => left.CompareTo(right) < 0; - - public static bool operator >(ClickHouseDecimal left, ClickHouseDecimal right) => left.CompareTo(right) > 0; - - public static bool operator <=(ClickHouseDecimal left, ClickHouseDecimal right) => left.CompareTo(right) <= 0; - - public static bool operator >=(ClickHouseDecimal left, ClickHouseDecimal right) => left.CompareTo(right) >= 0; - - public static explicit operator decimal(ClickHouseDecimal value) => value.ToDecimal(); - - public static explicit operator ClickHouseDecimal(decimal value) => FromDecimal(value); + /// Compares two values for equality, aligning their scales first. + /// The left value. + /// The right value. + /// True when they represent the same number. + public static bool operator ==(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => left.Equals(right); + + /// Compares two values for inequality, aligning their scales first. + /// The left value. + /// The right value. + /// True when they represent different numbers. + public static bool operator !=(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => !left.Equals(right); + + /// Orders two values, aligning their scales first. + /// The left value. + /// The right value. + /// True when is the smaller number. + public static bool operator <(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => left.CompareTo(right) < 0; + + /// Orders two values, aligning their scales first. + /// The left value. + /// The right value. + /// True when is the larger number. + public static bool operator >(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => left.CompareTo(right) > 0; + + /// Orders two values, aligning their scales first. + /// The left value. + /// The right value. + /// True when is not the larger number. + public static bool operator <=(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => left.CompareTo(right) <= 0; + + /// Orders two values, aligning their scales first. + /// The left value. + /// The right value. + /// True when is not the smaller number. + public static bool operator >=(ClickHouseTcpDecimal left, ClickHouseTcpDecimal right) => left.CompareTo(right) >= 0; + + /// Narrows to a , which holds fewer digits. + /// The value to narrow. + /// The same number as a . + /// The value does not fit a ; use to test first. + public static explicit operator decimal(ClickHouseTcpDecimal value) => value.ToDecimal(); + + /// Widens a , which always fits. + /// The value to widen. + /// The same number, with the 's own scale. + public static explicit operator ClickHouseTcpDecimal(decimal value) => FromDecimal(value); } diff --git a/ClickHouse.Driver.Tcp/Numerics/Int256.cs b/ClickHouse.Driver.Tcp/Numerics/Int256.cs index 0e4db4f3e..5ccc201a2 100644 --- a/ClickHouse.Driver.Tcp/Numerics/Int256.cs +++ b/ClickHouse.Driver.Tcp/Numerics/Int256.cs @@ -4,7 +4,7 @@ using System.Numerics; using System.Runtime.InteropServices; -namespace ClickHouse.Driver.Tcp.Numerics; +namespace ClickHouse.Driver.Tcp; /// /// A signed 256-bit integer (two's complement), the CLR representation of ClickHouse Int256. @@ -140,15 +140,39 @@ public int CompareTo(Int256 other) /// public override string ToString() => ToBigInteger().ToString(CultureInfo.InvariantCulture); + /// Compares two values for equality. + /// The left value. + /// The right value. + /// True when they are the same number. public static bool operator ==(Int256 left, Int256 right) => left.Equals(right); + /// Compares two values for inequality. + /// The left value. + /// The right value. + /// True when they are different numbers. public static bool operator !=(Int256 left, Int256 right) => !left.Equals(right); + /// Orders two values as signed numbers. + /// The left value. + /// The right value. + /// True when is the smaller number. public static bool operator <(Int256 left, Int256 right) => left.CompareTo(right) < 0; + /// Orders two values as signed numbers. + /// The left value. + /// The right value. + /// True when is the larger number. public static bool operator >(Int256 left, Int256 right) => left.CompareTo(right) > 0; + /// Orders two values as signed numbers. + /// The left value. + /// The right value. + /// True when is not the larger number. public static bool operator <=(Int256 left, Int256 right) => left.CompareTo(right) <= 0; + /// Orders two values as signed numbers. + /// The left value. + /// The right value. + /// True when is not the smaller number. public static bool operator >=(Int256 left, Int256 right) => left.CompareTo(right) >= 0; } diff --git a/ClickHouse.Driver.Tcp/Numerics/UInt256.cs b/ClickHouse.Driver.Tcp/Numerics/UInt256.cs index 6ffef2226..0f9519ee2 100644 --- a/ClickHouse.Driver.Tcp/Numerics/UInt256.cs +++ b/ClickHouse.Driver.Tcp/Numerics/UInt256.cs @@ -4,7 +4,7 @@ using System.Numerics; using System.Runtime.InteropServices; -namespace ClickHouse.Driver.Tcp.Numerics; +namespace ClickHouse.Driver.Tcp; /// /// An unsigned 256-bit integer, the CLR representation of ClickHouse UInt256. @@ -127,15 +127,39 @@ public int CompareTo(UInt256 other) /// public override string ToString() => ToBigInteger().ToString(CultureInfo.InvariantCulture); + /// Compares two values for equality. + /// The left value. + /// The right value. + /// True when they are the same number. public static bool operator ==(UInt256 left, UInt256 right) => left.Equals(right); + /// Compares two values for inequality. + /// The left value. + /// The right value. + /// True when they are different numbers. public static bool operator !=(UInt256 left, UInt256 right) => !left.Equals(right); + /// Orders two values as unsigned numbers. + /// The left value. + /// The right value. + /// True when is the smaller number. public static bool operator <(UInt256 left, UInt256 right) => left.CompareTo(right) < 0; + /// Orders two values as unsigned numbers. + /// The left value. + /// The right value. + /// True when is the larger number. public static bool operator >(UInt256 left, UInt256 right) => left.CompareTo(right) > 0; + /// Orders two values as unsigned numbers. + /// The left value. + /// The right value. + /// True when is not the larger number. public static bool operator <=(UInt256 left, UInt256 right) => left.CompareTo(right) <= 0; + /// Orders two values as unsigned numbers. + /// The left value. + /// The right value. + /// True when is not the smaller number. public static bool operator >=(UInt256 left, UInt256 right) => left.CompareTo(right) >= 0; } diff --git a/ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs b/ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs index 02819f442..88dcc6239 100644 --- a/ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs +++ b/ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs @@ -4,7 +4,6 @@ using System.Net; using System.Net.Sockets; using System.Runtime.CompilerServices; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types; namespace ClickHouse.Driver.Tcp.Parameters; @@ -48,7 +47,7 @@ public static string Infer(object value, string parameterName) // The scale is the value's own, so a round trip keeps every digit the caller supplied. case decimal d: return $"Decimal128({(decimal.GetBits(d)[3] >> 16) & 0x7F})"; - case ClickHouseDecimal chd: return $"Decimal128({chd.Scale})"; + case ClickHouseTcpDecimal chd: return $"Decimal128({chd.Scale})"; case string or char or byte[]: return "String"; case Guid: return "UUID"; @@ -129,7 +128,7 @@ public static bool Accepts(TypeNode node, object value) // These share one CLR type with several ClickHouse types, so the base name alone decides. string or char => node.Name is "String" or "FixedString" or "Enum8" or "Enum16" ? node.Name : "String", DateTime or DateTimeOffset => node.Name is "DateTime" or "DateTime64" or "Date" or "Date32" ? node.Name : "DateTime64", - decimal or ClickHouseDecimal => node.Name.StartsWith("Decimal", StringComparison.Ordinal) ? node.Name : "Decimal128", + decimal or ClickHouseTcpDecimal => node.Name.StartsWith("Decimal", StringComparison.Ordinal) ? node.Name : "Decimal128", not string and IEnumerable => "Array", _ => InferOrNothing(value), }; diff --git a/ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs b/ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs index 349e63ba0..99cb00513 100644 --- a/ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs +++ b/ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs @@ -6,7 +6,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types; using ClickHouse.Driver.Tcp.Types.Codecs; @@ -264,7 +263,7 @@ private static string BytesToSqlText(byte[] bytes) private static string FormatDecimal(object value) => value switch { - ClickHouseDecimal chd => chd.ToString(null, CultureInfo.InvariantCulture), + ClickHouseTcpDecimal chd => chd.ToString(null, CultureInfo.InvariantCulture), string s => ParseDecimalText(s).ToString(null, CultureInfo.InvariantCulture), _ => Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), }; @@ -281,11 +280,11 @@ private static string BytesToSqlText(byte[] bytes) /// , because a decimal caps at 29 digits and the wider ClickHouse decimals exceed /// that; only the plain form reaches that path, which is the only form that can be that wide. /// - private static ClickHouseDecimal ParseDecimalText(string text) + private static ClickHouseTcpDecimal ParseDecimalText(string text) { if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal narrow)) { - return ClickHouseDecimal.FromDecimal(narrow); + return ClickHouseTcpDecimal.FromDecimal(narrow); } string trimmed = text.Trim(); @@ -298,7 +297,7 @@ private static ClickHouseDecimal ParseDecimalText(string text) throw new ArgumentException($"Cannot convert value '{text}' to a ClickHouse decimal"); } - return new ClickHouseDecimal(mantissa, scale); + return new ClickHouseTcpDecimal(mantissa, scale); } private static string FormatDate(object value, bool quote) diff --git a/ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs b/ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs index 08b373c79..85964b42a 100644 --- a/ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs +++ b/ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs @@ -5,7 +5,7 @@ namespace ClickHouse.Driver.Tcp.Poco; /// internal enum PocoScatterTier { - /// Hoists and indexes its span. + /// Hoists and indexes its span. Span, /// diff --git a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryReader.cs b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryReader.cs index 8a70a2f91..1a805890a 100644 --- a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryReader.cs +++ b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryReader.cs @@ -5,7 +5,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; namespace ClickHouse.Driver.Tcp.Protocol; diff --git a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs index 9465dbdcc..c1740757e 100644 --- a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs +++ b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs @@ -5,7 +5,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; namespace ClickHouse.Driver.Tcp.Protocol; diff --git a/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs b/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs index 73d477e9e..4678b7f60 100644 --- a/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs +++ b/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs @@ -97,8 +97,11 @@ internal sealed class ClickHouseTcpConnection : IDisposable, IAsyncDisposable /// How long the server may stay silent mid-response before the operation fails. /// leaves the caller's token as the only bound, which is the default for the scripted-stream seam. /// + /// The host is big-endian. internal ClickHouseTcpConnection(Stream stream, Socket socket, IClickHouseCompressor compressor = null, TimeSpan readTimeout = default) { + HostEndianness.RequireLittleEndian(); + this.stream = stream; this.socket = socket; this.compressor = compressor; diff --git a/ClickHouse.Driver.Tcp/Protocol/HostEndianness.cs b/ClickHouse.Driver.Tcp/Protocol/HostEndianness.cs new file mode 100644 index 000000000..fc3999020 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Protocol/HostEndianness.cs @@ -0,0 +1,28 @@ +using System; + +namespace ClickHouse.Driver.Tcp.Protocol; + +/// +/// Asserts the little-endian host the column codecs assume. +/// +/// +/// The codecs reinterpret wire bytes as CLR values with MemoryMarshal.Cast and write them back with +/// MemoryMarshal.AsBytes. The native protocol is little-endian, so those casts are correct only on a +/// little-endian host. Every runtime .NET currently supports is little-endian, so this is a guard rather than +/// the alternative to a byte-swapping path. +/// +internal static class HostEndianness +{ + /// Throws if this host is big-endian. + /// The host is big-endian. + public static void RequireLittleEndian() + { + if (!BitConverter.IsLittleEndian) + { + throw new PlatformNotSupportedException( + "ClickHouse.Driver.Tcp requires a little-endian host: the native protocol is little-endian and " + + "the column codecs map wire bytes onto CLR values directly. Use the HTTP driver " + + "(ClickHouse.Driver) on this platform."); + } + } +} diff --git a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Shipped.txt b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 000000000..e69de29bb diff --git a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..73e5a4f00 --- /dev/null +++ b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,498 @@ +ClickHouse.Driver.Tcp.Block +ClickHouse.Driver.Tcp.Block.Column(int index) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.Block.Column(string name) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.Block.ColumnCount.get -> int +ClickHouse.Driver.Tcp.Block.ColumnNames.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.Block.Columns.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.Block.Dispose() -> void +ClickHouse.Driver.Tcp.Block.Name.get -> string +ClickHouse.Driver.Tcp.Block.RowCount.get -> int +ClickHouse.Driver.Tcp.Block.this[int index].get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.Block.this[string name].get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.Block.TryGetColumn(string name, out ClickHouse.Driver.Tcp.IColumn column) -> bool +ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.Aborted = 236 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.AccessDenied = 497 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.AllConnectionTriesFailed = 279 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.AttemptToReadAfterEof = 32 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.AuthenticationFailed = 516 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotConvertType = 70 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotInsertNullInOrdinaryColumn = 349 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotParseInputAssertionFailed = 27 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotParseText = 6 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotReadAllData = 33 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.ClientHasConnectedToWrongPort = 217 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.DatabaseAlreadyExists = 82 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.DuplicateColumn = 15 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.IllegalColumn = 44 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.IllegalTypeOfArgument = 43 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.IpAddressNotAllowed = 195 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.KeeperException = 999 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.LogicalError = 49 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.MemoryLimitExceeded = 241 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.NetworkError = 210 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.NoFreeConnection = 203 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.NoSuchColumnInTable = 16 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.NotImplemented = 48 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.NumberOfArgumentsDoesntMatch = 42 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.QueryWasCancelled = 394 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.ReadOnly = 164 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.RequiredPassword = 194 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.ServerOverloaded = 745 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.SessionIsLocked = 373 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.SessionNotFound = 372 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.SocketTimeout = 209 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.SyntaxError = 62 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TableAlreadyExists = 57 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TableIsDropped = 218 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TableIsReadOnly = 242 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TimeoutExceeded = 159 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TooManyBytes = 307 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TooManyParts = 252 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TooManyRows = 158 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TooManySimultaneousQueries = 202 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TooSlow = 160 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.TypeMismatch = 53 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnexpectedPacketFromServer = 102 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.Unknown = -1 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownDatabase = 81 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownFunction = 46 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownIdentifier = 47 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownPacketFromClient = 99 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownSetting = 115 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownTable = 60 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownType = 50 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownUser = 192 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnsupportedMethod = 1 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.ValueIsOutOfRangeOfDataType = 321 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.WrongPassword = 193 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ClickHouseTcpClientOptions() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Compressor.get -> ClickHouse.Driver.Compression.IClickHouseCompressor +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Compressor.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ConfigureTls.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ConfigureTls.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.CustomSettings.get -> System.Collections.Generic.IReadOnlyDictionary +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.CustomSettings.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Database.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Database.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.DialTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.DialTimeout.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Host.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Host.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.IdleTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.IdleTimeout.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.IncludeSqlInActivityTags.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.IncludeSqlInActivityTags.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.LoggerFactory.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxConnectionLifetime.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxConnectionLifetime.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxPoolSize.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxPoolSize.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxSendBufferBytes.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MaxSendBufferBytes.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MinPoolSize.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.MinPoolSize.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Password.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Password.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.PoolReusePolicy.get -> ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.PoolReusePolicy.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.PoolTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.PoolTimeout.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Port.get -> int? +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Port.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.QuotaKey.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.QuotaKey.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ReadTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ReadTimeout.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.StatementMaxLength.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.StatementMaxLength.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.SweepInterval.get -> System.TimeSpan? +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.SweepInterval.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsAllowInvalidCertificates.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsAllowInvalidCertificates.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsCaCertificatePath.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsCaCertificatePath.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsServerName.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsServerName.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Username.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Username.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.UseTls.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.UseTls.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpColumn +ClickHouse.Driver.Tcp.ClickHouseTcpColumnAttribute +ClickHouse.Driver.Tcp.ClickHouseTcpColumnAttribute.ClickHouseTcpColumnAttribute() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpColumnAttribute.Name.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpColumnAttribute.Name.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.ClickHouseTcpConnectionStringBuilder() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.ClickHouseTcpConnectionStringBuilder(string connectionString) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Compression.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Compression.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Database.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Database.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.DialTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.DialTimeout.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Host.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Host.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.IdleTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.IdleTimeout.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxConnectionLifetime.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxConnectionLifetime.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxPoolSize.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxPoolSize.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxSendBufferBytes.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MaxSendBufferBytes.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MinPoolSize.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.MinPoolSize.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Password.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Password.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.PoolReusePolicy.get -> ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.PoolReusePolicy.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.PoolTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.PoolTimeout.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Port.get -> int? +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Port.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.QuotaKey.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.QuotaKey.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.ReadTimeout.get -> System.TimeSpan +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.ReadTimeout.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.SweepInterval.get -> System.TimeSpan? +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.SweepInterval.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsAllowInvalidCertificates.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsAllowInvalidCertificates.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsCaCertificatePath.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsCaCertificatePath.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsServerName.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.TlsServerName.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.ToOptions() -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Username.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Username.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.UseTls.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.UseTls.set -> void +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ClickHouseTcpDecimal() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ClickHouseTcpDecimal(ClickHouse.Driver.Tcp.Int256 mantissa, int scale) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ClickHouseTcpDecimal(System.Int128 mantissa, int scale) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ClickHouseTcpDecimal(System.Numerics.BigInteger mantissa, int scale) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.CompareTo(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal other) -> int +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.Mantissa.get -> ClickHouse.Driver.Tcp.Int256 +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.Scale.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.Sign.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ToDecimal() -> decimal +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ToString(string format, System.IFormatProvider formatProvider) -> string +ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.TryToDecimal(out decimal value) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics +ClickHouse.Driver.Tcp.ClickHouseTcpException +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.ClickHouseTcpInsertOptions() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.MaxRowsPerBlock.get -> int? +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.MaxRowsPerBlock.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpNotMappedAttribute +ClickHouse.Driver.Tcp.ClickHouseTcpNotMappedAttribute.ClickHouseTcpNotMappedAttribute() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameter +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpParameter +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.ClickHouseTcpParameter(string Name, object Value, string ClickHouseType = null) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.ClickHouseType.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.ClickHouseType.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Deconstruct(out string Name, out object Value, out string ClickHouseType) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpParameter other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Name.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Name.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Value.get -> object +ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Value.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.Add(ClickHouse.Driver.Tcp.ClickHouseTcpParameter parameter) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.Add(string name, object value) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.Add(string name, object value, string clickHouseType) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.ClickHouseTcpParameterCollection() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.ClickHouseTcpParameterCollection(System.Collections.Generic.IEnumerable parameters) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.Contains(string name) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.Count.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.GetEnumerator() -> System.Collections.Generic.IEnumerator +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.this[string name].get -> ClickHouse.Driver.Tcp.ClickHouseTcpParameter +ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection.TryGetValue(string name, out ClickHouse.Driver.Tcp.ClickHouseTcpParameter parameter) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy +ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy.Fifo = 1 -> ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy +ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy.Lifo = 0 -> ClickHouse.Driver.Tcp.ClickHouseTcpPoolReusePolicy +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.AppliedLimit.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.Blocks.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.Bytes.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.CalculatedRowsBeforeLimit.get -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.ClickHouseTcpProfileInfo() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.ClickHouseTcpProfileInfo(ulong rows, ulong blocks, ulong bytes, bool appliedLimit, ulong rowsBeforeLimit, bool calculatedRowsBeforeLimit) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.Rows.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.RowsBeforeLimit.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.Bytes.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.ClickHouseTcpProgress() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.ClickHouseTcpProgress(ulong rows, ulong bytes, ulong totalRows, ulong wroteRows, ulong wroteBytes, ulong elapsedNs) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.ElapsedNs.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpProgress other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.Rows.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.TotalRows.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.WroteBytes.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProgress.WroteRows.get -> ulong +ClickHouse.Driver.Tcp.ClickHouseTcpProtocolException +ClickHouse.Driver.Tcp.ClickHouseTcpProtocolException.ClickHouseTcpProtocolException(string message) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpProtocolException.ClickHouseTcpProtocolException(string message, System.Exception innerException) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.ClickHouseTcpQueryCallbacks() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnExtremes.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnExtremes.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnLog.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnLog.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProfileEvents.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProfileEvents.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProfileInfo.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProfileInfo.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProgress.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnProgress.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnTotals.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnTotals.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Callbacks.get -> ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Callbacks.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.ClickHouseTcpQueryOptions() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.ClickHouseTcpQueryOptions(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions original) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Parameters.get -> ClickHouse.Driver.Tcp.ClickHouseTcpParameterCollection +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Parameters.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.QueryId.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.QueryId.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Settings.get -> System.Collections.Generic.IReadOnlyDictionary +ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Settings.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerException +ClickHouse.Driver.Tcp.ClickHouseTcpServerException.ClickHouseTcpServerException(int code, string name, string message, string serverStackTrace, System.Exception innerException = null) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerException.Code.get -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseTcpServerException.Name.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpServerException.RawCode.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerException.ServerStackTrace.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ClickHouseTcpServerInfo() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.DisplayName.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.DisplayName.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Name.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Name.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ProtocolRevision.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ProtocolRevision.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Timezone.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Timezone.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Version.get -> System.Version +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionMajor.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionMajor.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionMinor.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionMinor.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionPatch.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.VersionPatch.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpTransportException +ClickHouse.Driver.Tcp.ClickHouseTcpTransportException.ClickHouseTcpTransportException(string message, System.Exception innerException) -> void +ClickHouse.Driver.Tcp.IArrayColumn +ClickHouse.Driver.Tcp.IArrayColumn.Inner.get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IArrayColumn.InnerValues.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IArrayColumn.Offsets.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IColumn.ElementType.get -> System.Type +ClickHouse.Driver.Tcp.IColumn.GetValue(int row) -> object +ClickHouse.Driver.Tcp.IColumn.Name.get -> string +ClickHouse.Driver.Tcp.IColumn.RowCount.get -> int +ClickHouse.Driver.Tcp.IColumn.TypeName.get -> string +ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IColumn.this[int row].get -> T +ClickHouse.Driver.Tcp.IColumn.Values.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IDynamicColumn +ClickHouse.Driver.Tcp.IDynamicColumn.Discriminators.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IDynamicColumn.GetTypeColumn(int discriminator) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IDynamicColumn.LocalIndices.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IDynamicColumn.TypeCount.get -> int +ClickHouse.Driver.Tcp.IDynamicColumn.TypeNames.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.ILowCardinalityColumn +ClickHouse.Driver.Tcp.ILowCardinalityColumn.Dictionary.get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.ILowCardinalityColumn.Keys.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.ILowCardinalityColumn.ReservedSlotCount.get -> int +ClickHouse.Driver.Tcp.IMapColumn +ClickHouse.Driver.Tcp.IMapColumn.KeyColumn.get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IMapColumn.Offsets.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IMapColumn.ValueColumn.get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.INestedColumn +ClickHouse.Driver.Tcp.INestedColumn.FieldCount.get -> int +ClickHouse.Driver.Tcp.INestedColumn.FieldNames.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.INestedColumn.GetField(int index) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.INestedColumn.GetField(string name) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.INestedColumn.Offsets.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.Int256 +ClickHouse.Driver.Tcp.Int256.CompareTo(ClickHouse.Driver.Tcp.Int256 other) -> int +ClickHouse.Driver.Tcp.Int256.Equals(ClickHouse.Driver.Tcp.Int256 other) -> bool +ClickHouse.Driver.Tcp.Int256.Int256() -> void +ClickHouse.Driver.Tcp.Int256.Int256(ulong e0, ulong e1, ulong e2, ulong e3) -> void +ClickHouse.Driver.Tcp.Int256.IsNegative.get -> bool +ClickHouse.Driver.Tcp.Int256.ToBigInteger() -> System.Numerics.BigInteger +ClickHouse.Driver.Tcp.Int256.WriteLittleEndian(System.Span destination) -> void +ClickHouse.Driver.Tcp.INullableColumn +ClickHouse.Driver.Tcp.INullableColumn.Inner.get -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.INullableColumn.NullMap.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IQBitColumn +ClickHouse.Driver.Tcp.IQBitColumn.BitWidth.get -> int +ClickHouse.Driver.Tcp.IQBitColumn.BytesPerRow.get -> int +ClickHouse.Driver.Tcp.IQBitColumn.Dimension.get -> int +ClickHouse.Driver.Tcp.IQBitColumn.GetPlane(int bit) -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IQBitColumn.GetPlane(int bit, int group) -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IQBitColumn.GroupCount.get -> int +ClickHouse.Driver.Tcp.IQBitColumn.Stride.get -> int +ClickHouse.Driver.Tcp.ITupleColumn +ClickHouse.Driver.Tcp.ITupleColumn.Children.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.ITupleColumn.FieldNames.get -> System.Collections.Generic.IReadOnlyList +ClickHouse.Driver.Tcp.IVariantColumn +ClickHouse.Driver.Tcp.IVariantColumn.Discriminators.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IVariantColumn.GetTypeColumn(int discriminator) -> ClickHouse.Driver.Tcp.IColumn +ClickHouse.Driver.Tcp.IVariantColumn.LocalIndices.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IVariantColumn.TypeCount.get -> int +ClickHouse.Driver.Tcp.UInt256 +ClickHouse.Driver.Tcp.UInt256.CompareTo(ClickHouse.Driver.Tcp.UInt256 other) -> int +ClickHouse.Driver.Tcp.UInt256.Equals(ClickHouse.Driver.Tcp.UInt256 other) -> bool +ClickHouse.Driver.Tcp.UInt256.ToBigInteger() -> System.Numerics.BigInteger +ClickHouse.Driver.Tcp.UInt256.UInt256() -> void +ClickHouse.Driver.Tcp.UInt256.UInt256(ulong e0, ulong e1, ulong e2, ulong e3) -> void +ClickHouse.Driver.Tcp.UInt256.WriteLittleEndian(System.Span destination) -> void +const ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics.ActivitySourceName = "ClickHouse.Driver.Tcp" -> string +const ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics.ClientLogCategory = "ClickHouse.Driver.Tcp.Client" -> string +const ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics.ConnectionLogCategory = "ClickHouse.Driver.Tcp.Connection" -> string +const ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics.PoolLogCategory = "ClickHouse.Driver.Tcp.Pool" -> string +const ClickHouse.Driver.Tcp.Int256.Size = 32 -> int +const ClickHouse.Driver.Tcp.IVariantColumn.NullDiscriminator = 255 -> byte +const ClickHouse.Driver.Tcp.UInt256.Size = 32 -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.Remove(string keyword) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.this[string keyword].get -> object +override ClickHouse.Driver.Tcp.ClickHouseTcpConnectionStringBuilder.this[string keyword].set -> void +override ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions +override ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpParameter.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpParameter.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpParameter.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpProgress.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpProgress.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpProgress.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpServerException.IsTransient.get -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ToString() -> string +override ClickHouse.Driver.Tcp.ClickHouseTcpTransportException.IsTransient.get -> bool +override ClickHouse.Driver.Tcp.Int256.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.Int256.GetHashCode() -> int +override ClickHouse.Driver.Tcp.Int256.ToString() -> string +override ClickHouse.Driver.Tcp.UInt256.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.UInt256.GetHashCode() -> int +override ClickHouse.Driver.Tcp.UInt256.ToString() -> string +override sealed ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions other) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.FromConnectionString(string connectionString) -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpColumn.Create(string name, System.Collections.Generic.IEnumerable values) -> ClickHouse.Driver.Tcp.IColumn +static ClickHouse.Driver.Tcp.ClickHouseTcpColumn.Create(string name, T[] values) -> ClickHouse.Driver.Tcp.IColumn +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.explicit operator ClickHouse.Driver.Tcp.ClickHouseTcpDecimal(decimal value) -> ClickHouse.Driver.Tcp.ClickHouseTcpDecimal +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.explicit operator decimal(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal value) -> decimal +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.FromDecimal(decimal value) -> ClickHouse.Driver.Tcp.ClickHouseTcpDecimal +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator <(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator <=(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator >(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpDecimal.operator >=(ClickHouse.Driver.Tcp.ClickHouseTcpDecimal left, ClickHouse.Driver.Tcp.ClickHouseTcpDecimal right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpParameter.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpParameter left, ClickHouse.Driver.Tcp.ClickHouseTcpParameter right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpParameter.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpParameter left, ClickHouse.Driver.Tcp.ClickHouseTcpParameter right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo left, ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo left, ClickHouse.Driver.Tcp.ClickHouseTcpProfileInfo right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpProgress.Add(ClickHouse.Driver.Tcp.ClickHouseTcpProgress left, ClickHouse.Driver.Tcp.ClickHouseTcpProgress right) -> ClickHouse.Driver.Tcp.ClickHouseTcpProgress +static ClickHouse.Driver.Tcp.ClickHouseTcpProgress.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpProgress left, ClickHouse.Driver.Tcp.ClickHouseTcpProgress right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpProgress.operator +(ClickHouse.Driver.Tcp.ClickHouseTcpProgress left, ClickHouse.Driver.Tcp.ClickHouseTcpProgress right) -> ClickHouse.Driver.Tcp.ClickHouseTcpProgress +static ClickHouse.Driver.Tcp.ClickHouseTcpProgress.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpProgress left, ClickHouse.Driver.Tcp.ClickHouseTcpProgress right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo left, ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo left, ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo right) -> bool +static ClickHouse.Driver.Tcp.Int256.FromBigInteger(System.Numerics.BigInteger value) -> ClickHouse.Driver.Tcp.Int256 +static ClickHouse.Driver.Tcp.Int256.operator !=(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.operator <(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.operator <=(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.operator ==(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.operator >(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.operator >=(ClickHouse.Driver.Tcp.Int256 left, ClickHouse.Driver.Tcp.Int256 right) -> bool +static ClickHouse.Driver.Tcp.Int256.ReadLittleEndian(System.ReadOnlySpan source) -> ClickHouse.Driver.Tcp.Int256 +static ClickHouse.Driver.Tcp.Int256.Zero.get -> ClickHouse.Driver.Tcp.Int256 +static ClickHouse.Driver.Tcp.UInt256.FromBigInteger(System.Numerics.BigInteger value) -> ClickHouse.Driver.Tcp.UInt256 +static ClickHouse.Driver.Tcp.UInt256.operator !=(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.operator <(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.operator <=(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.operator ==(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.operator >(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.operator >=(ClickHouse.Driver.Tcp.UInt256 left, ClickHouse.Driver.Tcp.UInt256 right) -> bool +static ClickHouse.Driver.Tcp.UInt256.ReadLittleEndian(System.ReadOnlySpan source) -> ClickHouse.Driver.Tcp.UInt256 +static ClickHouse.Driver.Tcp.UInt256.Zero.get -> ClickHouse.Driver.Tcp.UInt256 +virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions +virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.EqualityContract.get -> System.Type +virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions other) -> bool +virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.PrintMembers(System.Text.StringBuilder builder) -> bool +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ClickHouseTcpClient(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions options) -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ClickHouseTcpClient(string connectionString) -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.DisposeAsync() -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ExecuteAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ExecuteScalarAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.GetServerInfoAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.InsertAsync(string sql, System.Collections.Generic.IReadOnlyList columns, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.InsertRowsAsync(string sql, System.Collections.Generic.IReadOnlyList rows, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.InsertRowsAsync(string sql, System.Collections.Generic.IReadOnlyList rows, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.OpenSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.Options.get -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.PingAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.QueryAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.QueryAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.StreamAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.ClickHouseTcpDataSource(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions options) -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.ClickHouseTcpDataSource(string connectionString) -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.Dispose() -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.DisposeAsync() -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.GetClient() -> ClickHouse.Driver.Tcp.IClickHouseTcpClient +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.OpenSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.Options.get -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpClient +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpClient.OpenSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.ExecuteAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.ExecuteScalarAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.GetServerInfoAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.InsertAsync(string sql, System.Collections.Generic.IReadOnlyList columns, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.InsertRowsAsync(string sql, System.Collections.Generic.IReadOnlyList rows, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.InsertRowsAsync(string sql, System.Collections.Generic.IReadOnlyList rows, ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.Options.get -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.PingAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.QueryAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.QueryAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.StreamAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpSession +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpSession.IsOpen.get -> bool \ No newline at end of file diff --git a/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs b/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs new file mode 100644 index 000000000..fa11d471c --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClickHouse.Driver.Tcp.Types; + +namespace ClickHouse.Driver.Tcp; + +/// +/// Builds the columns an takes, one call per target column. +/// This is the columnar insert tier: you hand over data already grouped by column, so nothing is transposed and +/// nothing is boxed. To insert rows instead, use InsertRowsAsync. +/// +/// +/// +/// Give each column the target column's name: an insert matches by name, not position, so the order is +/// free and naming a subset of the table inserts only those columns, the server filling the rest from their +/// defaults. You do not state the ClickHouse type — the server sends the target's schema before any row data and +/// that is what the values are serialized as, so a column built here reports a null +/// . If is not a CLR type the target column accepts, +/// the insert fails with an naming both. +/// +/// +/// Pick to match the target: Int32 takes an int, String a +/// string, Array(UInt32) a uint[] per row, Nullable(Int32) an int?. A row of +/// an Array(T) may not be null — use for an empty row, or make the target +/// Array(Nullable(T)) to carry null elements. +/// +/// +/// A column read out of a can be passed straight back to an insert without going through +/// this factory, and re-inserts without being rebuilt. +/// +/// +public static class ClickHouseTcpColumn +{ + /// + /// Builds a column over a caller-supplied array, one entry per row. The array is taken over as is, not + /// copied, so do not modify it until the insert has completed. + /// + /// The CLR type of one row's value. + /// The target column's name. + /// The values, in row order; its length is the row count. + /// A column ready to insert. + /// or is null. + public static IColumn Create(string name, T[] values) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(values); + return new ArrayColumn(name, typeName: null, values); + } + + /// + /// Builds a column from any sequence, which is enumerated once into an array. A sequence that already is a + /// [] is taken over rather than copied, so the array overload's rule applies + /// to it too: do not modify it until the insert has completed. + /// + /// The CLR type of one row's value. + /// The target column's name. + /// The values, in row order. + /// A column ready to insert. + /// or is null. + public static IColumn Create(string name, IEnumerable values) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(values); + return Create(name, values as T[] ?? values.ToArray()); + } +} diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/DecimalColumnCodec.cs b/ClickHouse.Driver.Tcp/Types/Codecs/DecimalColumnCodec.cs index 4f1d86c7e..86c635287 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/DecimalColumnCodec.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/DecimalColumnCodec.cs @@ -6,7 +6,6 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Protocol; namespace ClickHouse.Driver.Tcp.Types.Codecs; @@ -19,12 +18,12 @@ namespace ClickHouse.Driver.Tcp.Types.Codecs; /// /// /// The 4- and 8-byte widths surface as ; the wider ones, which exceed the range of -/// , surface as . The mantissa bytes are read in one bulk +/// , surface as . The mantissa bytes are read in one bulk /// transfer, then converted to the CLR value. /// /// /// The unmanaged backing integer (int, long, Int128, or Int256). -/// The CLR value type (decimal or ClickHouseDecimal). +/// The CLR value type (decimal or ClickHouseTcpDecimal). internal sealed class DecimalColumnCodec : IColumnCodec where TMantissa : unmanaged, IComparable { @@ -173,8 +172,8 @@ public static IColumnCodec Create(TypeNode node) { 4 => new DecimalColumnCodec(typeName, precision, scale, (int)minMantissa, (int)maxMantissa, DecimalConvert.DecodeInt32, DecimalConvert.EncodeInt32), 8 => new DecimalColumnCodec(typeName, precision, scale, (long)minMantissa, (long)maxMantissa, DecimalConvert.DecodeInt64, DecimalConvert.EncodeInt64), - 16 => new DecimalColumnCodec(typeName, precision, scale, Int128.CreateChecked(minMantissa), Int128.CreateChecked(maxMantissa), DecimalConvert.DecodeInt128, DecimalConvert.EncodeInt128), - _ => new DecimalColumnCodec(typeName, precision, scale, Int256.FromBigInteger(minMantissa), Int256.FromBigInteger(maxMantissa), DecimalConvert.DecodeInt256, DecimalConvert.EncodeInt256), + 16 => new DecimalColumnCodec(typeName, precision, scale, Int128.CreateChecked(minMantissa), Int128.CreateChecked(maxMantissa), DecimalConvert.DecodeInt128, DecimalConvert.EncodeInt128), + _ => new DecimalColumnCodec(typeName, precision, scale, Int256.FromBigInteger(minMantissa), Int256.FromBigInteger(maxMantissa), DecimalConvert.DecodeInt256, DecimalConvert.EncodeInt256), }; } @@ -218,13 +217,13 @@ public static int EncodeInt32(decimal value, int scale) public static long EncodeInt64(decimal value, int scale) => EncodeDecimalMantissa(value, scale); - public static ClickHouseDecimal DecodeInt128(Int128 mantissa, int scale) => new(mantissa, scale); + public static ClickHouseTcpDecimal DecodeInt128(Int128 mantissa, int scale) => new(mantissa, scale); - public static ClickHouseDecimal DecodeInt256(Int256 mantissa, int scale) => new(mantissa, scale); + public static ClickHouseTcpDecimal DecodeInt256(Int256 mantissa, int scale) => new(mantissa, scale); - public static Int128 EncodeInt128(ClickHouseDecimal value, int scale) => Int128.CreateChecked(RescaleMantissa(value, scale)); + public static Int128 EncodeInt128(ClickHouseTcpDecimal value, int scale) => Int128.CreateChecked(RescaleMantissa(value, scale)); - public static Int256 EncodeInt256(ClickHouseDecimal value, int scale) => Int256.FromBigInteger(RescaleMantissa(value, scale)); + public static Int256 EncodeInt256(ClickHouseTcpDecimal value, int scale) => Int256.FromBigInteger(RescaleMantissa(value, scale)); private static decimal MakeDecimal(long mantissa, int scale) { @@ -255,7 +254,7 @@ private static long EncodeDecimalMantissa(decimal value, int scale) return (long)truncated; } - private static BigInteger RescaleMantissa(ClickHouseDecimal value, int scale) + private static BigInteger RescaleMantissa(ClickHouseTcpDecimal value, int scale) { BigInteger mantissa = value.Mantissa.ToBigInteger(); int diff = scale - value.Scale; diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/DynamicTypeInference.cs b/ClickHouse.Driver.Tcp/Types/Codecs/DynamicTypeInference.cs index 27ecbf5ad..794e30f17 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/DynamicTypeInference.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/DynamicTypeInference.cs @@ -5,7 +5,6 @@ using System.Net.Sockets; using System.Reflection; using System.Runtime.CompilerServices; -using ClickHouse.Driver.Tcp.Numerics; namespace ClickHouse.Driver.Tcp.Types.Codecs; @@ -57,7 +56,7 @@ internal static class DynamicTypeInference /// The canonical ClickHouse type string and the value coerced to that codec's CLR element type — so the write /// path can bucket it as the element type the codec reads back. For most types the value is returned as-is; a /// / becomes a raw nanosecond count - /// (DateTime64(9)'s element type) and a a . + /// (DateTime64(9)'s element type) and a a . /// /// is null. /// No ClickHouse type is inferred for the value's CLR type. @@ -72,7 +71,7 @@ public static (string TypeName, object Value) Infer(object value) // Types whose ClickHouse mapping depends on the value (scale) or that map to a canonical read-back type // wider than the input CLR type. The value is coerced to that codec's element type so it round-trips: - // - a decimal maps to Decimal128 (element type ClickHouseDecimal), a ClickHouseDecimal to Decimal256; + // - a decimal maps to Decimal128 (element type ClickHouseTcpDecimal), a ClickHouseTcpDecimal to Decimal256; // - a DateTimeOffset/DateTime maps to DateTime64 at nanosecond scale, coerced to the codec's Int64 count // element type (exact for either — both hold at most 100 ns ticks). A raw Int64 is Int64, not // DateTime64: there is no distinct CLR carrier to key on, so date-time semantics require a @@ -83,10 +82,10 @@ public static (string TypeName, object Value) Infer(object value) return ("DateTime64(9)", ToNanosecondCount(dateTimeOffset)); case DateTime dateTime: return ("DateTime64(9)", ToNanosecondCount(new DateTimeOffset(dateTime.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dateTime, DateTimeKind.Utc) : dateTime))); - case ClickHouseDecimal wideDecimal: + case ClickHouseTcpDecimal wideDecimal: return (FormattableString.Invariant($"Decimal(76, {wideDecimal.Scale})"), value); case decimal value128: - return (FormattableString.Invariant($"Decimal(38, {ScaleOf(value128)})"), ClickHouseDecimal.FromDecimal(value128)); + return (FormattableString.Invariant($"Decimal(38, {ScaleOf(value128)})"), ClickHouseTcpDecimal.FromDecimal(value128)); } Type type = value.GetType(); @@ -133,7 +132,7 @@ private static string InferArrayOrMap(Array array) } // A Map's key and value types are inferred from the present pairs, the same way an Array's element type is, so - // that a value-disambiguated type (an IPAddress's family, a ClickHouseDecimal's scale) resolves as a Map key or + // that a value-disambiguated type (an IPAddress's family, a ClickHouseTcpDecimal's scale) resolves as a Map key or // value too. The scan runs through a cached per-pair-type delegate, over the typed pairs, so it neither boxes // each pair nor reflects over Key/Value once per entry. private static (string Key, string Value) ScanPairs(Array pairs) @@ -207,7 +206,7 @@ private static string InferComposable(object element) if (canonical.GetType() != element.GetType()) { throw new NotSupportedException( - $"A Dynamic composite element of CLR type '{element.GetType()}' would be coerced to '{canonical.GetType()}', which is not supported inside a composite. Use the canonical element type directly (e.g. a raw long count for a DateTime64, ClickHouseDecimal for a decimal)."); + $"A Dynamic composite element of CLR type '{element.GetType()}' would be coerced to '{canonical.GetType()}', which is not supported inside a composite. Use the canonical element type directly (e.g. a raw long count for a DateTime64, ClickHouseTcpDecimal for a decimal)."); } return typeName; diff --git a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs index 703ccbfd6..10ab1c3d9 100644 --- a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs +++ b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using ClickHouse.Driver.Tcp.Numerics; using ClickHouse.Driver.Tcp.Types.Codecs; namespace ClickHouse.Driver.Tcp.Types; diff --git a/ClickHouse.Driver.Tcp/Types/ColumnElementTypes.cs b/ClickHouse.Driver.Tcp/Types/ColumnElementTypes.cs new file mode 100644 index 000000000..e22d25a7f --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/ColumnElementTypes.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Concurrent; + +namespace ClickHouse.Driver.Tcp.Types; + +/// Resolves and caches the T in . +internal static class ColumnElementTypes +{ + private static readonly ConcurrentDictionary Cache = new(); + + /// Returns the element type surfaces. + /// The type implements zero or multiple interfaces. + public static Type Of(Type columnType) => Cache.GetOrAdd(columnType, static type => + { + Type found = null; + foreach (Type candidate in type.GetInterfaces()) + { + if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IColumn<>)) + { + continue; + } + + if (found is not null) + { + throw new InvalidOperationException($"Column type '{type}' implements IColumn<> more than once, so it has no single element type."); + } + + found = candidate.GenericTypeArguments[0]; + } + + return found ?? throw new InvalidOperationException($"Column type '{type}' does not implement IColumn<>."); + }); +} diff --git a/ClickHouse.Driver.Tcp/Types/DynamicColumn.cs b/ClickHouse.Driver.Tcp/Types/DynamicColumn.cs index 1d0796c48..6bb2ddb8b 100644 --- a/ClickHouse.Driver.Tcp/Types/DynamicColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/DynamicColumn.cs @@ -185,59 +185,3 @@ public void Dispose() cache = null; } } - -/// -/// The columnar read surface of a decoded Dynamic column: the runtime type-name list discovered on the wire, a -/// per-row discriminator stream, and one child column per runtime type, each holding only the values of the rows that -/// selected it, in row order. It is a read view only — implementing it does not make a column insertable, because the -/// codec's zero-copy write path accepts only columns this driver decoded, whose invariants it has checked. -/// -/// -/// Like , a Dynamic has no useful materialized element type — its -/// surface is IColumn<object>, so every row read through it is boxed — and the -/// columnar view is the typed way in. Row i's value lives at LocalIndices[i] within -/// GetTypeColumn(Discriminators[i]). Unlike Variant, whose NULL discriminator is the fixed value 255, -/// a Dynamic marks NULL with — one past the last type — because the type list is -/// discovered per block rather than declared. A NULL row occupies no slot in any child. -/// -/// -/// -/// The type names are the wire's own spelling of each runtime type, in discriminator order, so a caller can decide -/// how to read a child without inspecting its values. Child columns and both spans are borrowed views over the -/// owning block's storage: read them in place, and copy out only what must outlive the block. Obtain this view by -/// pattern-matching a column, e.g. if (column is IDynamicColumn dynamicColumn). -/// -/// -public interface IDynamicColumn : IColumn -{ - /// - /// The number of runtime types; also the NULL discriminator value, since NULL is encoded as one past the last - /// type rather than a fixed sentinel. - /// - int TypeCount { get; } - - /// - /// The runtime type names, in wire (discriminator) order — the ClickHouse type string for each child column, - /// which is how a caller knows what to read that child as. Read-only; the underlying storage is not exposed. - /// - IReadOnlyList TypeNames { get; } - - /// One discriminator per row; marks a NULL row. - ReadOnlySpan Discriminators { get; } - - /// - /// Each row's index into its selected type's child column (the count of that discriminator in the rows before - /// it), precomputed once; a NULL row's entry is -1. Lets a caller price or address a row in O(1) - /// rather than rescanning the discriminators. - /// - ReadOnlySpan LocalIndices { get; } - - /// - /// The child column for the given discriminator (holding the values of the rows that selected it). A borrowed - /// view valid only while the owning block is alive — it is the block's to dispose, never the caller's. - /// - /// The runtime-type index. Must be a real type index: the NULL discriminator () selects no column, so guard for it before calling. - /// That type's child column. - /// is negative or not less than — which includes passing the NULL discriminator. - IColumn GetTypeColumn(int discriminator); -} diff --git a/ClickHouse.Driver.Tcp/Types/IArrayColumn.cs b/ClickHouse.Driver.Tcp/Types/IArrayColumn.cs index c1df1ad0b..b3efc74e2 100644 --- a/ClickHouse.Driver.Tcp/Types/IArrayColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IArrayColumn.cs @@ -1,6 +1,6 @@ using System; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded Array(T) column. An array column materializes each row as a diff --git a/ClickHouse.Driver.Tcp/Types/IColumn.cs b/ClickHouse.Driver.Tcp/Types/IColumn.cs index 8084d53c1..56e186409 100644 --- a/ClickHouse.Driver.Tcp/Types/IColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IColumn.cs @@ -1,6 +1,7 @@ using System; +using ClickHouse.Driver.Tcp.Types; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// A decoded column: a named, typed sequence of values read from one block. The generic @@ -9,8 +10,8 @@ namespace ClickHouse.Driver.Tcp.Types; /// /// /// A column's storage may be a pooled buffer, so it is disposable and its values are borrowed for the block's -/// lifetime. The owning disposes its columns; a consumer must not read a column -/// after the block is released (see for the borrowing contract). +/// lifetime. The owning disposes its columns; a consumer must not read a column +/// after the block is released (see for the borrowing contract). /// /// public interface IColumn : IDisposable @@ -18,7 +19,11 @@ public interface IColumn : IDisposable /// The column name from the block header. string Name { get; } - /// The ClickHouse type string from the block header (e.g. UInt64, String). + /// + /// The ClickHouse type string from the block header (e.g. UInt64, String). Null for a column + /// built by , which has no header: an insert takes the type from the + /// target's schema, so the caller never states one. + /// string TypeName { get; } /// The number of values in the column. @@ -33,35 +38,6 @@ public interface IColumn : IDisposable Type ElementType => ColumnElementTypes.Of(GetType()); } -/// Resolves and caches the T in . -internal static class ColumnElementTypes -{ - private static readonly System.Collections.Concurrent.ConcurrentDictionary Cache = new(); - - /// Returns the element type surfaces. - /// The type implements zero or multiple interfaces. - public static Type Of(Type columnType) => Cache.GetOrAdd(columnType, static type => - { - Type found = null; - foreach (Type candidate in type.GetInterfaces()) - { - if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IColumn<>)) - { - continue; - } - - if (found is not null) - { - throw new InvalidOperationException($"Column type '{type}' implements IColumn<> more than once, so it has no single element type."); - } - - found = candidate.GenericTypeArguments[0]; - } - - return found ?? throw new InvalidOperationException($"Column type '{type}' does not implement IColumn<>."); - }); -} - /// /// A decoded column with typed access. is a borrowed view valid for the lifetime of the /// block: process it in place, or copy it (e.g. ToArray()) to retain the data beyond the block. diff --git a/ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs b/ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs new file mode 100644 index 000000000..810971106 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace ClickHouse.Driver.Tcp; + +/// +/// The columnar read surface of a decoded Dynamic column: the runtime type-name list discovered on the wire, a +/// per-row discriminator stream, and one child column per runtime type, each holding only the values of the rows that +/// selected it, in row order. It is a read view only — implementing it does not make a column insertable, because the +/// codec's zero-copy write path accepts only columns this driver decoded, whose invariants it has checked. +/// +/// +/// Like , a Dynamic has no useful materialized element type — its +/// surface is IColumn<object>, so every row read through it is boxed — and the +/// columnar view is the typed way in. Row i's value lives at LocalIndices[i] within +/// GetTypeColumn(Discriminators[i]). Unlike Variant, whose NULL discriminator is the fixed value 255, +/// a Dynamic marks NULL with — one past the last type — because the type list is +/// discovered per block rather than declared. A NULL row occupies no slot in any child. +/// +/// +/// +/// The type names are the wire's own spelling of each runtime type, in discriminator order, so a caller can decide +/// how to read a child without inspecting its values. Child columns and both spans are borrowed views over the +/// owning block's storage: read them in place, and copy out only what must outlive the block. Obtain this view by +/// pattern-matching a column, e.g. if (column is IDynamicColumn dynamicColumn). +/// +/// +public interface IDynamicColumn : IColumn +{ + /// + /// The number of runtime types; also the NULL discriminator value, since NULL is encoded as one past the last + /// type rather than a fixed sentinel. + /// + int TypeCount { get; } + + /// + /// The runtime type names, in wire (discriminator) order — the ClickHouse type string for each child column, + /// which is how a caller knows what to read that child as. Read-only; the underlying storage is not exposed. + /// + IReadOnlyList TypeNames { get; } + + /// One discriminator per row; marks a NULL row. + ReadOnlySpan Discriminators { get; } + + /// + /// Each row's index into its selected type's child column (the count of that discriminator in the rows before + /// it), precomputed once; a NULL row's entry is -1. Lets a caller price or address a row in O(1) + /// rather than rescanning the discriminators. + /// + ReadOnlySpan LocalIndices { get; } + + /// + /// The child column for the given discriminator (holding the values of the rows that selected it). A borrowed + /// view valid only while the owning block is alive — it is the block's to dispose, never the caller's. + /// + /// The runtime-type index. Must be a real type index: the NULL discriminator () selects no column, so guard for it before calling. + /// That type's child column. + /// is negative or not less than — which includes passing the NULL discriminator. + IColumn GetTypeColumn(int discriminator); +} diff --git a/ClickHouse.Driver.Tcp/Types/ILowCardinalityColumn.cs b/ClickHouse.Driver.Tcp/Types/ILowCardinalityColumn.cs index 3f47acb31..2d588441d 100644 --- a/ClickHouse.Driver.Tcp/Types/ILowCardinalityColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/ILowCardinalityColumn.cs @@ -1,6 +1,6 @@ using System; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded LowCardinality(T) or LowCardinality(Nullable(T)) column: diff --git a/ClickHouse.Driver.Tcp/Types/IMapColumn.cs b/ClickHouse.Driver.Tcp/Types/IMapColumn.cs index d49373bea..7261d734f 100644 --- a/ClickHouse.Driver.Tcp/Types/IMapColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IMapColumn.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded Map(K, V) column. A map column materializes each row as a freshly diff --git a/ClickHouse.Driver.Tcp/Types/INestedColumn.cs b/ClickHouse.Driver.Tcp/Types/INestedColumn.cs index 8492bd88c..3596a5eb7 100644 --- a/ClickHouse.Driver.Tcp/Types/INestedColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/INestedColumn.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded Nested(name1 T1, ..., namen Tn) column carried as one wire column diff --git a/ClickHouse.Driver.Tcp/Types/INullableColumn.cs b/ClickHouse.Driver.Tcp/Types/INullableColumn.cs index 5ff724f2c..de4a098df 100644 --- a/ClickHouse.Driver.Tcp/Types/INullableColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/INullableColumn.cs @@ -1,6 +1,6 @@ using System; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded Nullable(T) column. A nullable column materializes each row as diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs index ff8a3a294..b29239f65 100644 --- a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -1,6 +1,6 @@ using System; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The columnar read surface of a decoded QBit(T, N) column. A QBit row is an N-element vector diff --git a/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs b/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs index 8ad36efdf..ee3a02bce 100644 --- a/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace ClickHouse.Driver.Tcp.Types; +namespace ClickHouse.Driver.Tcp; /// /// The read surface of a decoded Tuple(...) column. A tuple is stored on the wire — and here — as its N diff --git a/ClickHouse.Driver.Tcp/Types/IVariantColumn.cs b/ClickHouse.Driver.Tcp/Types/IVariantColumn.cs new file mode 100644 index 000000000..d81f1b0c7 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/IVariantColumn.cs @@ -0,0 +1,52 @@ +using System; + +namespace ClickHouse.Driver.Tcp; + +/// +/// The columnar read surface of a decoded Variant(T1, ..., Tn) column: a per-row discriminator stream plus one +/// child column per alternative type, each holding only the values of the rows that selected it, in row order. That +/// is exactly the wire layout. It is a read view only — implementing it does not make a column insertable, because +/// the codec's zero-copy write path accepts only columns this driver decoded, whose invariants it has checked. +/// +/// +/// Unlike the other composites, a Variant has no useful materialized element type — the +/// surface is IColumn<object>, so every row read through it is boxed. Reading +/// columnar avoids that: dispatch on , then read the selected type's child column, +/// which is typed. Row i's value lives at LocalIndices[i] within +/// GetTypeColumn(Discriminators[i]), unless its discriminator is +/// , in which case the row is NULL and occupies no slot in any child. +/// +/// +/// +/// Child columns and both spans are borrowed views over the owning block's storage: read them in place, and copy out +/// only what must outlive the block. A child whose type is itself a composite pattern-matches to that type's own +/// columnar view. Obtain this view by pattern-matching a column, e.g. if (column is IVariantColumn variant). +/// +/// +public interface IVariantColumn : IColumn +{ + /// The discriminator value marking a NULL row; it selects no alternative type. + public const byte NullDiscriminator = 255; + + /// The number of alternative types. + int TypeCount { get; } + + /// One discriminator per row; marks a NULL row. + ReadOnlySpan Discriminators { get; } + + /// + /// Each row's index into its selected type's child column (the count of that discriminator in the rows before + /// it), precomputed once; a NULL row's entry is -1. Lets a caller price or address a row in O(1) + /// rather than rescanning the discriminators. + /// + ReadOnlySpan LocalIndices { get; } + + /// + /// The child column for the given discriminator (holding the values of the rows that selected it). A borrowed + /// view valid only while the owning block is alive — it is the block's to dispose, never the caller's. + /// + /// The alternative-type index. Must be a real type index: selects no column, so guard for it before calling. + /// That type's child column. + /// is negative or not less than — which includes passing . + IColumn GetTypeColumn(int discriminator); +} diff --git a/ClickHouse.Driver.Tcp/Types/VariantColumn.cs b/ClickHouse.Driver.Tcp/Types/VariantColumn.cs index 849171f1b..7607e04ec 100644 --- a/ClickHouse.Driver.Tcp/Types/VariantColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/VariantColumn.cs @@ -195,52 +195,3 @@ public void Dispose() cache = null; } } - -/// -/// The columnar read surface of a decoded Variant(T1, ..., Tn) column: a per-row discriminator stream plus one -/// child column per alternative type, each holding only the values of the rows that selected it, in row order. That -/// is exactly the wire layout. It is a read view only — implementing it does not make a column insertable, because -/// the codec's zero-copy write path accepts only columns this driver decoded, whose invariants it has checked. -/// -/// -/// Unlike the other composites, a Variant has no useful materialized element type — the -/// surface is IColumn<object>, so every row read through it is boxed. Reading -/// columnar avoids that: dispatch on , then read the selected type's child column, -/// which is typed. Row i's value lives at LocalIndices[i] within -/// GetTypeColumn(Discriminators[i]), unless its discriminator is -/// , in which case the row is NULL and occupies no slot in any child. -/// -/// -/// -/// Child columns and both spans are borrowed views over the owning block's storage: read them in place, and copy out -/// only what must outlive the block. A child whose type is itself a composite pattern-matches to that type's own -/// columnar view. Obtain this view by pattern-matching a column, e.g. if (column is IVariantColumn variant). -/// -/// -public interface IVariantColumn : IColumn -{ - /// The discriminator value marking a NULL row; it selects no alternative type. - public const byte NullDiscriminator = 255; - - /// The number of alternative types. - int TypeCount { get; } - - /// One discriminator per row; marks a NULL row. - ReadOnlySpan Discriminators { get; } - - /// - /// Each row's index into its selected type's child column (the count of that discriminator in the rows before - /// it), precomputed once; a NULL row's entry is -1. Lets a caller price or address a row in O(1) - /// rather than rescanning the discriminators. - /// - ReadOnlySpan LocalIndices { get; } - - /// - /// The child column for the given discriminator (holding the values of the rows that selected it). A borrowed - /// view valid only while the owning block is alive — it is the block's to dispose, never the caller's. - /// - /// The alternative-type index. Must be a real type index: selects no column, so guard for it before calling. - /// That type's child column. - /// is negative or not less than — which includes passing . - IColumn GetTypeColumn(int discriminator); -} diff --git a/Directory.Packages.props b/Directory.Packages.props index 4329a7504..c22961cfe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/changelog.d/418-tcp-native-client.features.md b/changelog.d/418-tcp-native-client.features.md new file mode 100644 index 000000000..0114ed31a --- /dev/null +++ b/changelog.d/418-tcp-native-client.features.md @@ -0,0 +1,4 @@ +* Added `ClickHouseTcpClient`, a client that speaks ClickHouse's native TCP protocol instead of HTTP (issue #418). It reads results either columnwise as borrowed `Block`s or row by row as `object[]` or a mapped POCO, inserts columnwise or row by row, pools connections, and offers sessions pinned to one connection for temporary tables and `SET`. TLS, query parameters, named server error codes, OpenTelemetry tracing and `ILogger` output are all supported. + - **Compression is on by default, using LZ4.** Set `Compression=none` in the connection string, or `ClickHouseTcpClientOptions.Compressor`, to change or disable it. This matches `clickhouse-client`. + - The client ships in the same `ClickHouse.Driver` package, but targets `net8.0` and newer, so it is absent from the package's `net6.0` build. + - The client, session and data source types are marked `[Experimental("CHTCP0001")]`: the surface may change in a future release. Suppress that diagnostic to acknowledge it. From 57253e4191edb2e34eba0d59d9ca39c81bca48f7 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 28 Aug 2026 23:57:23 +0200 Subject: [PATCH 2/6] Read the whole result in ExecuteScalarAsync Returning at the first value left the stream early, which is the abandon path: the connection sends Cancel and terminates. On a session that destroyed the session outright, temporary tables and settings included, for a query as small as SELECT count(); on a pooled client it cost a reconnect on every call. Draining to completion keeps the connection. Every row now crosses the wire, so the remarks say so and point large reads at StreamAsync. The test that claimed to prove connection reuse only proved the pool could redial after a termination. Both scalar-lifetime tests now run on a session with a temporary table as the marker, which is the one thing that cannot survive a replaced connection. Co-Authored-By: Claude Opus 5 (1M context) --- .../PublicSurfaceIntegrationTests.cs | 40 +++++++++++++++---- .../Client/ClickHouseTcpClient.cs | 15 +++++-- .../Client/IClickHouseTcpOperations.cs | 13 +++--- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs index 3149eeb7b..b09227b75 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs @@ -85,21 +85,47 @@ public async Task ExecuteScalarAsync_EmptyResult_ReturnsNull() } [Test] - public async Task ExecuteScalarAsync_AbandonsALargeResult_LeavesTheConnectionReusable() + public async Task ExecuteScalarAsync_OnASession_KeepsTheConnectionAndItsTemporaryTables() { - // Stopping after the first row cancels the rest of the result. The connection goes back to the pool, so - // the next operation on the same client must succeed rather than trip over leftover bytes. + // The server scopes a temporary table to the connection that made it, so reading one back after a + // scalar query is the only proof that the scalar did not cost the session its pinned connection. + // Returning early from the stream would take the abandon path, which cancels and terminates. await using var client = TcpServerFixture.CreateClient(); + await using IClickHouseTcpSession session = await client.OpenSessionAsync(None); + + await session.ExecuteAsync("CREATE TEMPORARY TABLE scalar_session_marker (id UInt8)", cancellationToken: None); + await session.ExecuteAsync("INSERT INTO scalar_session_marker VALUES (1), (2)", cancellationToken: None); + + object viaScalar = await session.ExecuteScalarAsync("SELECT count() FROM scalar_session_marker", cancellationToken: None); + object stillThere = await session.ExecuteScalarAsync("SELECT sum(id) FROM scalar_session_marker", cancellationToken: None); + + Assert.Multiple(() => + { + Assert.That(viaScalar, Is.EqualTo(2UL)); + Assert.That(stillThere, Is.EqualTo(3UL)); + }); + } + + [Test] + public async Task ExecuteScalarAsync_ResultWithManyRows_ReadsThemAllAndKeepsTheConnection() + { + // Reading the whole result, rather than stopping at the first value, is what leaves the connection + // reusable: the abandon path terminates it. A temporary table on a session is the marker, since the + // pool would otherwise hide a termination by redialling. + await using var client = TcpServerFixture.CreateClient(); + await using IClickHouseTcpSession session = await client.OpenSessionAsync(None); + + await session.ExecuteAsync("CREATE TEMPORARY TABLE scalar_many_rows (id UInt8)", cancellationToken: None); - object first = await client.ExecuteScalarAsync( - "SELECT number FROM numbers(5000000) ORDER BY number", + object first = await session.ExecuteScalarAsync( + "SELECT number FROM numbers(100000) ORDER BY number", cancellationToken: None); - object second = await client.ExecuteScalarAsync("SELECT 42", cancellationToken: None); + object markerSurvived = await session.ExecuteScalarAsync("SELECT count() FROM scalar_many_rows", cancellationToken: None); Assert.Multiple(() => { Assert.That(first, Is.EqualTo(0UL)); - Assert.That(second, Is.EqualTo((byte)42)); + Assert.That(markerSurvived, Is.EqualTo(0UL)); }); } diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs index 89d8af426..7bddce957 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs @@ -371,18 +371,25 @@ public async ValueTask ExecuteScalarAsync( ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) { + object value = null; + bool found = false; + + // Reads to the end rather than returning at the first value. Leaving the stream early is the abandon + // path: it cancels the query and terminates the connection, which costs a pooled client a reconnect per + // call and costs a session its temporary tables and settings outright. await foreach (Block block in StreamAsync(sql, options, cancellationToken).ConfigureAwait(false)) { - if (block.RowCount == 0 || block.ColumnCount == 0) + if (found || block.RowCount == 0 || block.ColumnCount == 0) { continue; } - // Read before returning: the block is borrowed and StreamAsync disposes it as the loop unwinds. - return block[0].GetValue(0); + // Read inside the loop: the block is borrowed and StreamAsync disposes it on the next iteration. + value = block[0].GetValue(0); + found = true; } - return null; + return value; } /// diff --git a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs index 726fea645..047578270 100644 --- a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs +++ b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs @@ -109,14 +109,15 @@ ValueTask ExecuteAsync( /// version() or an EXISTS. /// /// - /// The returned value is owned and safe to retain. Rows after the first are not read: the result is abandoned - /// as soon as the value is in hand, which tells the server to stop producing it, so this is cheap even against - /// a query that would return many rows. A NULL in that cell comes back as null, the same as no rows at - /// all — select a non-nullable expression if the two need telling apart. + /// The returned value is owned and safe to retain. A NULL in that cell comes back as null, the same as + /// no rows at all — select a non-nullable expression if the two need telling apart. /// /// - /// Because the result is abandoned, the query's activity ends with no status set, the same as any other - /// enumeration stopped early. Treat a statusless span from this call as a success, not a failure. + /// The whole result is read, not just the first row. Values after the first are discarded, but they + /// still cross the wire, so write a query that returns one row rather than relying on this to stop early. + /// Stopping early is not an option: it would abandon the result, and abandoning cancels the query and closes + /// the connection — a reconnect per call on a pooled client, and the loss of a session's temporary tables and + /// settings. Use when you want to read part of a large result. /// /// /// The SQL text. From c1f2d3ca7bbc9e000890eff29b132f1cd308c99a Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sat, 29 Aug 2026 10:43:02 +0200 Subject: [PATCH 3/6] Register the native client in an IServiceCollection AddClickHouseTcpDataSource registers the data source and the client it owns, mirroring the HTTP AddClickHouseDataSource: a connection string, options, an options factory, or a data source factory, each with an optional service key. Only singletons, because the pool has to outlive every consumer. The data source hands out the client itself rather than a view that swallows disposal, so a consumer that disposes what it was injected closes the pool. The docs say so, and the pool teardown is idempotent, so the container disposing both the data source and the client at shutdown closes it once. ClickHouseTcpClient implements IDisposable alongside IAsyncDisposable so a container can dispose it under either path. A synchronous ServiceProvider.Dispose rejects a tracked service that offers only IAsyncDisposable, and rejects it instead of disposing the rest of its list. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClickHouse.Driver.Tcp.Tests.csproj | 2 + ...ouseTcpServiceCollectionExtensionsTests.cs | 247 ++++++++++++++++++ ...ClickHouseTcpDataSourceIntegrationTests.cs | 15 +- ...iceCollectionExtensionsIntegrationTests.cs | 44 ++++ .../ClickHouse.Driver.Tcp.csproj | 4 + .../Client/ClickHouseTcpClient.cs | 10 +- .../Client/ClickHouseTcpDataSource.cs | 74 +----- ...lickHouseTcpServiceCollectionExtensions.cs | 148 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 8 +- ClickHouse.Driver/ClickHouse.Driver.csproj | 4 + Directory.Packages.props | 2 + 11 files changed, 487 insertions(+), 71 deletions(-) create mode 100644 ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs create mode 100644 ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpServiceCollectionExtensionsIntegrationTests.cs create mode 100644 ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs diff --git a/ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj b/ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj index 73757ec44..512048c8d 100644 --- a/ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj +++ b/ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj @@ -23,6 +23,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs b/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..1a0c121b0 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace ClickHouse.Driver.Tcp.Tests.DependencyInjection; + +/// +/// Covers the registrations themselves: what is registered, at which lifetime, and who owns the pool. A data +/// source dials nothing until an operation runs, so none of this needs a server. +/// +[TestFixture] +public class ClickHouseTcpServiceCollectionExtensionsTests +{ + private const string ConnectionString = "Host=clickhouse.invalid;Port=9123;Username=someone;Database=somewhere"; + + private static ClickHouseTcpClientOptions Options() => new() { Host = "clickhouse.invalid", Port = 9123 }; + + [Test] + public async Task AddClickHouseTcpDataSource_WithConnectionString_ConfiguresTheDataSourceFromIt() + { + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(ConnectionString) + .BuildServiceProvider(); + + ClickHouseTcpClientOptions options = provider.GetRequiredService().Options; + + Assert.Multiple(() => + { + Assert.That(options.Host, Is.EqualTo("clickhouse.invalid")); + Assert.That(options.Port, Is.EqualTo(9123)); + Assert.That(options.Username, Is.EqualTo("someone")); + Assert.That(options.Database, Is.EqualTo("somewhere")); + }); + } + + [Test] + public void AddClickHouseTcpDataSource_WithOptions_RegistersOnlySingletons() + { + IServiceCollection services = new ServiceCollection().AddClickHouseTcpDataSource(Options()); + + Assert.That( + services.Select(descriptor => (descriptor.ServiceType, descriptor.Lifetime)), + Is.EquivalentTo(new[] + { + (typeof(ClickHouseTcpDataSource), ServiceLifetime.Singleton), + (typeof(IClickHouseTcpClient), ServiceLifetime.Singleton), + (typeof(IClickHouseTcpOperations), ServiceLifetime.Singleton), + })); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithOptions_ResolvesTheClientTheDataSourceOwns() + { + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options()) + .BuildServiceProvider(); + + var dataSource = provider.GetRequiredService(); + + Assert.Multiple(() => + { + Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource)); + Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource.GetClient())); + Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource.GetClient())); + }); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithoutALoggerFactoryInTheOptions_TakesTheProviderOne() + { + await using ServiceProvider provider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddClickHouseTcpDataSource(Options()) + .BuildServiceProvider(); + + ClickHouseTcpDataSource dataSource = provider.GetRequiredService(); + + Assert.That(dataSource.Options.LoggerFactory, Is.SameAs(NullLoggerFactory.Instance)); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithALoggerFactoryInTheOptions_KeepsIt() + { + var configured = Substitute.For(); + + await using ServiceProvider provider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddClickHouseTcpDataSource(Options() with { LoggerFactory = configured }) + .BuildServiceProvider(); + + ClickHouseTcpDataSource dataSource = provider.GetRequiredService(); + + Assert.That(dataSource.Options.LoggerFactory, Is.SameAs(configured)); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithAnOptionsFactory_RunsItOnceWithTheProvider() + { + int calls = 0; + + await using ServiceProvider provider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddClickHouseTcpDataSource(serviceProvider => + { + calls++; + return Options() with { Database = serviceProvider.GetRequiredService().GetType().Name }; + }) + .BuildServiceProvider(); + + ClickHouseTcpDataSource dataSource = provider.GetRequiredService(); + _ = provider.GetRequiredService(); + + Assert.Multiple(() => + { + Assert.That(dataSource.Options.Database, Is.EqualTo(nameof(NullLoggerFactory))); + Assert.That(calls, Is.EqualTo(1)); + }); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithADataSourceFactory_UsesWhatTheFactoryReturns() + { + var built = new ClickHouseTcpDataSource(Options() with { Database = "from_factory" }); + + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource((_, _) => built) + .BuildServiceProvider(); + + Assert.That(provider.GetRequiredService(), Is.SameAs(built)); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithAServiceKey_RegistersKeyedServicesOnly() + { + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options(), serviceKey: "reporting") + .BuildServiceProvider(); + + var dataSource = provider.GetRequiredKeyedService("reporting"); + + Assert.Multiple(() => + { + Assert.That(provider.GetRequiredKeyedService("reporting"), Is.SameAs(dataSource.GetClient())); + Assert.That(provider.GetRequiredKeyedService("reporting"), Is.SameAs(dataSource.GetClient())); + Assert.That(provider.GetService(), Is.Null); + Assert.That(provider.GetService(), Is.Null); + }); + } + + [Test] + public async Task AddClickHouseTcpDataSource_WithTwoServiceKeys_KeepsThePoolsApart() + { + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options() with { Database = "first" }, serviceKey: "first") + .AddClickHouseTcpDataSource(Options() with { Database = "second" }, serviceKey: "second") + .BuildServiceProvider(); + + var first = provider.GetRequiredKeyedService("first"); + var second = provider.GetRequiredKeyedService("second"); + + Assert.Multiple(() => + { + Assert.That(first.Options.Database, Is.EqualTo("first")); + Assert.That(second.Options.Database, Is.EqualTo("second")); + Assert.That(provider.GetRequiredKeyedService("second"), Is.SameAs(second.GetClient())); + }); + } + + [Test] + public async Task AddClickHouseTcpDataSource_CalledTwiceWithoutAKey_KeepsTheFirstRegistration() + { + await using ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options() with { Database = "first" }) + .AddClickHouseTcpDataSource(Options() with { Database = "second" }) + .BuildServiceProvider(); + + Assert.That(provider.GetRequiredService().Options.Database, Is.EqualTo("first")); + } + + [Test] + public void AddClickHouseTcpDataSource_WithANullArgument_Throws() + { + Assert.Multiple(() => + { + Assert.Throws(() => ClickHouseTcpServiceCollectionExtensions.AddClickHouseTcpDataSource(null, ConnectionString)); + Assert.Throws(() => new ServiceCollection().AddClickHouseTcpDataSource((string)null)); + Assert.Throws(() => new ServiceCollection().AddClickHouseTcpDataSource((ClickHouseTcpClientOptions)null)); + Assert.Throws(() => new ServiceCollection().AddClickHouseTcpDataSource((Func)null)); + Assert.Throws(() => new ServiceCollection().AddClickHouseTcpDataSource((Func)null)); + }); + } + + [Test] + public async Task DisposeAsync_OnTheProvider_ClosesThePoolOnceAndWithoutThrowing() + { + ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options()) + .BuildServiceProvider(); + + var dataSource = provider.GetRequiredService(); + IClickHouseTcpClient client = provider.GetRequiredService(); + + // The container holds both the data source and the client it owns, so it disposes the same pool twice. + await provider.DisposeAsync(); + + Assert.Multiple(() => + { + Assert.ThrowsAsync(async () => await client.PingAsync()); + Assert.DoesNotThrowAsync(async () => await dataSource.DisposeAsync()); + }); + } + + [Test] + public void Dispose_OnTheProviderWithOnlyTheDataSourceResolved_ClosesThePool() + { + ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options()) + .BuildServiceProvider(); + + IClickHouseTcpClient client = provider.GetRequiredService().GetClient(); + + provider.Dispose(); + + Assert.ThrowsAsync(async () => await client.PingAsync()); + } + + [Test] + public void Dispose_OnTheProviderWithTheClientResolved_ClosesThePool() + { + // The container tracks the resolved client, and a synchronous ServiceProvider.Dispose() rejects a + // tracked service that offers only IAsyncDisposable — rejecting it instead of disposing the rest of + // its list. ClickHouseTcpClient.Dispose exists so that neither happens here. + ServiceProvider provider = new ServiceCollection() + .AddClickHouseTcpDataSource(Options()) + .BuildServiceProvider(); + + using ClickHouseTcpDataSource dataSource = provider.GetRequiredService(); + IClickHouseTcpClient client = provider.GetRequiredService(); + + Assert.DoesNotThrow(provider.Dispose); + Assert.ThrowsAsync(async () => await client.PingAsync()); + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs index 2eb9096b0..74bd06a0e 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs @@ -4,8 +4,8 @@ namespace ClickHouse.Driver.Tcp.Tests.Integration; -// The point of the data source is ownership, and only a live pool can show it: a view that disposes itself -// must leave the pool usable, and disposing the data source must not. +// The point of the data source is ownership, and only a live pool can show it: the client it hands out is its +// own, so whoever disposes that client closes the pool. [TestFixture] [Category("Integration")] public class ClickHouseTcpDataSourceIntegrationTests @@ -24,21 +24,22 @@ public async Task GetClient_ReturnsTheSameInstanceEveryCall() } [Test] - public async Task GetClient_DisposedByAConsumer_LeavesThePoolWorking() + public async Task GetClient_DisposedByAConsumer_ClosesThePoolForEverybody() { - // A scoped service that disposes what it was injected must not close a pool it does not own. + // The data source hands out the client it owns, so a consumer that disposes what it was injected closes + // the shared pool. Consumers must leave disposal to the data source. await using ClickHouseTcpDataSource source = CreateDataSource(); IClickHouseTcpClient injected = source.GetClient(); await injected.PingAsync(None); await injected.DisposeAsync(); - object value = await source.GetClient().ExecuteScalarAsync("SELECT 1", cancellationToken: None); - Assert.That(value, Is.EqualTo((byte)1)); + Assert.ThrowsAsync( + async () => await source.GetClient().ExecuteScalarAsync("SELECT 1", cancellationToken: None)); } [Test] - public async Task DisposeAsync_ClosesThePool_SoTheViewStopsWorking() + public async Task DisposeAsync_ClosesThePool_SoTheClientStopsWorking() { ClickHouseTcpDataSource source = CreateDataSource(); IClickHouseTcpClient client = source.GetClient(); diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpServiceCollectionExtensionsIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpServiceCollectionExtensionsIntegrationTests.cs new file mode 100644 index 000000000..31f1ec697 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpServiceCollectionExtensionsIntegrationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace ClickHouse.Driver.Tcp.Tests.Integration; + +// What the registrations are is unit-tested without a server. What needs one: that the client the container hands +// out really runs on the configured endpoint, and that disposing the provider tears down a pool with a live +// connection in it — the container disposes both the data source and the client it owns, so the teardown runs twice. +[TestFixture] +[Category("Integration")] +public class ClickHouseTcpServiceCollectionExtensionsIntegrationTests +{ + private static readonly CancellationToken None = CancellationToken.None; + + private static ServiceProvider BuildProvider() + => new ServiceCollection() + .AddClickHouseTcpDataSource(TcpServerFixture.ConnectionString) + .BuildServiceProvider(); + + [Test] + public async Task AddClickHouseTcpDataSource_WithAConnectionString_ResolvesAClientThatReachesTheServer() + { + await using ServiceProvider provider = BuildProvider(); + + IClickHouseTcpClient client = provider.GetRequiredService(); + + object value = await client.ExecuteScalarAsync("SELECT 42", cancellationToken: None); + Assert.That(value, Is.EqualTo((byte)42)); + } + + [Test] + public async Task DisposeAsync_OnTheProvider_ClosesThePoolTheResolvedClientWasUsing() + { + ServiceProvider provider = BuildProvider(); + IClickHouseTcpClient client = provider.GetRequiredService(); + await client.PingAsync(None); + + await provider.DisposeAsync(); + + Assert.ThrowsAsync(async () => await client.PingAsync(None)); + } +} diff --git a/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj b/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj index f7c5ee109..f6225a386 100644 --- a/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj +++ b/ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj @@ -31,6 +31,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs index 7bddce957..e46823192 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs @@ -47,7 +47,7 @@ namespace ClickHouse.Driver.Tcp; /// /// [Experimental("CHTCP0001")] -public sealed class ClickHouseTcpClient : IClickHouseTcpClient +public sealed class ClickHouseTcpClient : IClickHouseTcpClient, IDisposable { // Reading and writing Dynamic requires the flattened native serialization; the client enables it on every // operation so callers never have to know about it. A caller-supplied value wins. @@ -628,6 +628,14 @@ public async ValueTask GetServerInfoAsync(CancellationT /// public ValueTask DisposeAsync() => source.DisposeAsync(); + /// + /// Closes the pool, blocking until it is closed. Present so a container that tracks this client can dispose it + /// synchronously: ServiceProvider.Dispose() rejects a singleton offering only + /// , and rejects it instead of disposing the rest of its list. Prefer + /// wherever the call site can await. + /// + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + private IReadOnlyDictionary BuildSettings(ClickHouseTcpQueryOptions options) => MergeSettings(Options.CustomSettings, options?.Settings); diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs index 49f6d39dc..91f8eda19 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -7,16 +6,17 @@ namespace ClickHouse.Driver.Tcp; /// -/// Owns one and its connection pool, and hands out views onto it that cannot -/// close it. Register this as a singleton in a dependency-injection container and let it be the thing that gets -/// disposed at shutdown; inject 's result everywhere else. +/// Owns one and its connection pool, and hands that client to everything that +/// runs operations against the server. Register this as a singleton in a dependency-injection container and let it +/// be the thing that gets disposed at shutdown; inject 's result everywhere else. +/// AddClickHouseTcpDataSource does both. /// /// /// /// A is already thread-safe and pooled, so this adds no pooling of its own. What -/// it adds is ownership: returns a client whose DisposeAsync does nothing, so a -/// scoped service that disposes what it was injected cannot take the shared pool down with it. Disposing the data -/// source closes the pool, once. +/// it adds is a single owner: the client, and the pool behind it, belong to the data source. Everything else holds +/// a client it must not dispose, because disposing it closes the pool for every other holder. Dispose the data +/// source instead, and the pool closes once. /// /// /// This type is experimental: its surface may change in a future release. Suppress diagnostic @@ -27,7 +27,6 @@ namespace ClickHouse.Driver.Tcp; public sealed class ClickHouseTcpDataSource : IAsyncDisposable, IDisposable { private readonly ClickHouseTcpClient client; - private readonly NonOwningClient view; /// Creates a data source from a connection string. /// The connection string (keys such as Host, Port, Username, set_<name>). @@ -45,18 +44,17 @@ public ClickHouseTcpDataSource(string connectionString) public ClickHouseTcpDataSource(ClickHouseTcpClientOptions options) { client = new ClickHouseTcpClient(options); - view = new NonOwningClient(client); } /// The configuration every operation from this data source runs under. public ClickHouseTcpClientOptions Options => client.Options; /// - /// Returns the shared client. The same instance every time, and disposing it does nothing — only disposing - /// the data source closes the pool. + /// Returns the shared client: the same instance on every call, and the data source's to dispose, not the + /// caller's. Disposing it closes the pool, which ends every other holder's operations as well. /// - /// A non-owning view of the shared client. - public IClickHouseTcpClient GetClient() => view; + /// The client this data source owns. + public IClickHouseTcpClient GetClient() => client; /// /// Opens a session on the shared pool: one connection, held until the session is disposed, that carries @@ -69,7 +67,7 @@ public ClickHouseTcpDataSource(ClickHouseTcpClientOptions options) public ValueTask OpenSessionAsync(CancellationToken cancellationToken = default) => client.OpenSessionAsync(cancellationToken); - /// Closes the pool and every connection in it. Views handed out by stop working. + /// Closes the pool and every connection in it. The client from stops working. /// A task that completes when the pool is closed. public ValueTask DisposeAsync() => client.DisposeAsync(); @@ -79,52 +77,4 @@ public ValueTask OpenSessionAsync(CancellationToken cance /// prefer wherever the call site can await. /// public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - /// Delegates every operation to the owned client and swallows disposal, so an injected consumer cannot close - /// a pool it does not own. - /// - private sealed class NonOwningClient(ClickHouseTcpClient inner) : IClickHouseTcpClient - { - public ClickHouseTcpClientOptions Options => inner.Options; - - public IAsyncEnumerable StreamAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) - => inner.StreamAsync(sql, options, cancellationToken); - - public IAsyncEnumerable QueryAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) - => inner.QueryAsync(sql, options, cancellationToken); - - public IAsyncEnumerable QueryAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) - where T : class - => inner.QueryAsync(sql, options, cancellationToken); - - public ValueTask ExecuteAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) - => inner.ExecuteAsync(sql, options, cancellationToken); - - public ValueTask ExecuteScalarAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) - => inner.ExecuteScalarAsync(sql, options, cancellationToken); - - public ValueTask InsertAsync(string sql, IReadOnlyList columns, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) - => inner.InsertAsync(sql, columns, options, cancellationToken); - - public ValueTask InsertRowsAsync(string sql, IReadOnlyList rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) - where T : class - => inner.InsertRowsAsync(sql, rows, options, cancellationToken); - - public ValueTask InsertRowsAsync(string sql, IReadOnlyList rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) - => inner.InsertRowsAsync(sql, rows, options, cancellationToken); - - public ValueTask PingAsync(CancellationToken cancellationToken = default) - => inner.PingAsync(cancellationToken); - - public ValueTask GetServerInfoAsync(CancellationToken cancellationToken = default) - => inner.GetServerInfoAsync(cancellationToken); - - public ValueTask OpenSessionAsync(CancellationToken cancellationToken = default) - => inner.OpenSessionAsync(cancellationToken); - - /// Does nothing: the data source owns the client. - /// A completed task. - public ValueTask DisposeAsync() => default; - } } diff --git a/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs b/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs new file mode 100644 index 000000000..c32ad5d06 --- /dev/null +++ b/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs @@ -0,0 +1,148 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using ClickHouse.Driver.Tcp; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up the ClickHouse native-protocol (TCP) client in an +/// . +/// +/// +/// +/// Every overload registers one as a singleton and resolves +/// and from it, so the whole +/// application shares one connection pool. Singleton is the only lifetime offered: the data source owns a pool +/// that has to outlive every consumer, and the client is that pool rather than a per-consumer resource. +/// +/// +/// Do not dispose the injected client. Disposing it closes the shared pool, so every other consumer's +/// operations fail from then on. Disposing the provider closes the pool once, at shutdown, which is what you +/// want; either disposal path works, though prefer await provider.DisposeAsync() where the call site can +/// await it, as a generic host does. +/// +/// +/// This type is experimental: its surface may change in a future release. Suppress diagnostic +/// CHTCP0001 to acknowledge that. +/// +/// +[Experimental("CHTCP0001")] +public static class ClickHouseTcpServiceCollectionExtensions +{ + /// + /// Registers a and the it owns in + /// the , configured from a connection string. + /// + /// The to add services to. + /// A ClickHouse native-protocol connection string (keys such as Host, Port, Username, set_<name>). + /// The of the registrations, or null for unkeyed ones. + /// The same service collection so that multiple calls can be chained. + /// or is null. + /// A resulting option value is invalid. + public static IServiceCollection AddClickHouseTcpDataSource( + this IServiceCollection services, + string connectionString, + object serviceKey = null) + { + ArgumentNullException.ThrowIfNull(connectionString); + + return AddClickHouseTcpDataSource( + services, + _ => ClickHouseTcpClientOptions.FromConnectionString(connectionString), + serviceKey); + } + + /// + /// Registers a and the it owns in + /// the , configured from options. + /// + /// The to add services to. + /// The client configuration (endpoint, credentials, timeouts, client-level settings). + /// The of the registrations, or null for unkeyed ones. + /// The same service collection so that multiple calls can be chained. + /// + /// A null is filled in from the container's + /// when it registers one; the options given here are left unchanged. + /// + /// or is null. + public static IServiceCollection AddClickHouseTcpDataSource( + this IServiceCollection services, + ClickHouseTcpClientOptions options, + object serviceKey = null) + { + ArgumentNullException.ThrowIfNull(options); + + return AddClickHouseTcpDataSource(services, _ => options, serviceKey); + } + + /// + /// Registers a and the it owns in + /// the , configured by an options factory with access to the service provider. + /// + /// The to add services to. + /// A factory that builds the client configuration. + /// The of the registrations, or null for unkeyed ones. + /// The same service collection so that multiple calls can be chained. + /// + /// The factory runs once, when the data source is first resolved. A null + /// on its result is filled in from the container's + /// when it registers one. + /// + /// or is null. + public static IServiceCollection AddClickHouseTcpDataSource( + this IServiceCollection services, + Func optionsFactory, + object serviceKey = null) + { + ArgumentNullException.ThrowIfNull(optionsFactory); + + return AddClickHouseTcpDataSource( + services, + (sp, _) => new ClickHouseTcpDataSource(WithLoggerFactory(optionsFactory(sp), sp)), + serviceKey); + } + + /// + /// Registers a built by a factory, and the + /// it owns, in the . + /// + /// The to add services to. + /// A factory for the , taking the service provider and the service key. + /// The of the registrations, or null for unkeyed ones. + /// The same service collection so that multiple calls can be chained. + /// + /// The factory owns the whole configuration, including ; + /// nothing is filled in from the container. Each service is added with + /// , so an + /// earlier registration of the same service (and key) wins. + /// + /// or is null. + public static IServiceCollection AddClickHouseTcpDataSource( + this IServiceCollection services, + Func dataSourceFactory, + object serviceKey = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(dataSourceFactory); + + services.TryAdd(new ServiceDescriptor(typeof(ClickHouseTcpDataSource), serviceKey, dataSourceFactory, ServiceLifetime.Singleton)); + services.TryAdd(new ServiceDescriptor(typeof(IClickHouseTcpClient), serviceKey, static (sp, key) => GetService(sp, key).GetClient(), ServiceLifetime.Singleton)); + services.TryAdd(new ServiceDescriptor(typeof(IClickHouseTcpOperations), serviceKey, static (sp, key) => GetService(sp, key), ServiceLifetime.Singleton)); + return services; + + static T GetService(IServiceProvider serviceProvider, object serviceKey) + => serviceKey == null ? serviceProvider.GetRequiredService() : serviceProvider.GetRequiredKeyedService(serviceKey); + } + + private static ClickHouseTcpClientOptions WithLoggerFactory(ClickHouseTcpClientOptions options, IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(options); + + return options.LoggerFactory == null + ? options with { LoggerFactory = serviceProvider.GetService() } + : options; + } +} diff --git a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt index 73e5a4f00..cee2ae4b4 100644 --- a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt +++ b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt @@ -459,6 +459,7 @@ virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.PrintMembers(System.Text [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ClickHouseTcpClient(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions options) -> void [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ClickHouseTcpClient(string connectionString) -> void +[CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.Dispose() -> void [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.DisposeAsync() -> System.Threading.Tasks.ValueTask [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ExecuteAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpClient.ExecuteScalarAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -495,4 +496,9 @@ virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.PrintMembers(System.Text [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.QueryAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.StreamAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpSession -[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpSession.IsOpen.get -> bool \ No newline at end of file +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpSession.IsOpen.get -> bool +[CHTCP0001]Microsoft.Extensions.DependencyInjection.ClickHouseTcpServiceCollectionExtensions +[CHTCP0001]static Microsoft.Extensions.DependencyInjection.ClickHouseTcpServiceCollectionExtensions.AddClickHouseTcpDataSource(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions options, object serviceKey = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +[CHTCP0001]static Microsoft.Extensions.DependencyInjection.ClickHouseTcpServiceCollectionExtensions.AddClickHouseTcpDataSource(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string connectionString, object serviceKey = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +[CHTCP0001]static Microsoft.Extensions.DependencyInjection.ClickHouseTcpServiceCollectionExtensions.AddClickHouseTcpDataSource(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Func optionsFactory, object serviceKey = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +[CHTCP0001]static Microsoft.Extensions.DependencyInjection.ClickHouseTcpServiceCollectionExtensions.AddClickHouseTcpDataSource(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Func dataSourceFactory, object serviceKey = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection diff --git a/ClickHouse.Driver/ClickHouse.Driver.csproj b/ClickHouse.Driver/ClickHouse.Driver.csproj index 873023257..84a7d430d 100644 --- a/ClickHouse.Driver/ClickHouse.Driver.csproj +++ b/ClickHouse.Driver/ClickHouse.Driver.csproj @@ -73,6 +73,10 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index c22961cfe..a54f70211 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,8 @@ + + From 8f9b8125e79f9d3f2edea4984708efb2063fedcd Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sat, 29 Aug 2026 11:42:49 +0200 Subject: [PATCH 4/6] Let the block tier read a temporal column as a calendar value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DateTime column surfaces as IColumn and a DateTime64 as IColumn, because that is the count the wire carried. Turning a count into an instant needs the timezone the column type declared, which no IColumn member reports and which the concrete column classes hold internally, so a caller on the block tier could not do it at all: only QueryAsync into a POCO converted. Time and Time64 were the same, minus the timezone. IDateTimeColumn and ITimeColumn expose that conversion the way IQBitColumn and IVariantColumn already expose their layouts — a public interface over an internal column, reached by pattern-matching. Scale says which unit the raw count is in, so a caller reading Values directly knows what it has. Co-Authored-By: Claude Opus 5 (1M context) --- .../ColumnarReadSurfaceIntegrationTests.cs | 114 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 9 ++ .../Types/DateTime64Column.cs | 2 +- ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs | 5 +- .../Types/IDateTimeColumn.cs | 53 ++++++++ ClickHouse.Driver.Tcp/Types/ITimeColumn.cs | 45 +++++++ ClickHouse.Driver.Tcp/Types/Time64Column.cs | 2 +- ClickHouse.Driver.Tcp/Types/TimeColumn.cs | 5 +- 8 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs create mode 100644 ClickHouse.Driver.Tcp/Types/ITimeColumn.cs diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs index 854b36d6c..50375bbea 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs @@ -785,4 +785,118 @@ FROM system.numbers LIMIT 4 Assert.That(materialized, Is.EqualTo(new object[] { "s0", null, 100L, "s3" })); }); } + + [Test] + public async Task StreamAsync_DateTimeColumn_ExposesTheDeclaredTimezoneAndInstantsThroughIDateTimeColumn() + { + // A DateTime column's IColumn surface is IColumn: the epoch seconds the wire carried. Turning + // those into an instant needs the timezone the column type declares, which no IColumn member reports, so + // IDateTimeColumn is the only way to do it from the block tier. + await using var client = TcpServerFixture.CreateClient(); + + bool matched = false; + string timeZoneId = null; + int scale = 0; + var offsets = Array.Empty(); + DateTimeOffset first = default; + uint firstRaw = 0; + + await foreach (Block block in client.StreamAsync( + "SELECT toDateTime('2024-06-15 14:00:00', 'Europe/Amsterdam') + number FROM system.numbers LIMIT 3", + cancellationToken: None)) + { + IColumn column = block[0]; + matched = column is IDateTimeColumn; + + var instants = (IDateTimeColumn)column; + timeZoneId = instants.TimeZone.Id; + scale = instants.Scale; + offsets = instants.ToDateTimeOffsets(); + first = instants.GetDateTimeOffset(0); + firstRaw = ((IColumn)column).Values[0]; + } + + Assert.Multiple(() => + { + Assert.That(matched, Is.True); + Assert.That(timeZoneId, Is.EqualTo("Europe/Amsterdam"), "the timezone the column type declared, not the server's"); + Assert.That(scale, Is.EqualTo(0), "DateTime counts whole seconds"); + Assert.That(offsets, Has.Length.EqualTo(3)); + Assert.That(first, Is.EqualTo(offsets[0]), "the per-row and whole-column reads agree"); + Assert.That(first.Offset, Is.EqualTo(TimeSpan.FromHours(2)), "June is CEST, so +02:00"); + Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss"), Is.EqualTo("2024-06-15 14:00:00")); + Assert.That(first.ToUnixTimeSeconds(), Is.EqualTo(firstRaw), "the instant is the raw count, presented"); + Assert.That(offsets[2] - offsets[0], Is.EqualTo(TimeSpan.FromSeconds(2))); + }); + } + + [Test] + public async Task StreamAsync_DateTime64Column_ReportsItsScaleAndSubSecondPrecisionThroughIDateTimeColumn() + { + await using var client = TcpServerFixture.CreateClient(); + + bool matched = false; + int scale = 0; + DateTimeOffset first = default; + long firstRaw = 0; + + await foreach (Block block in client.StreamAsync( + "SELECT toDateTime64('2024-06-15 14:00:00.125', 3, 'UTC') FROM system.numbers LIMIT 1", + cancellationToken: None)) + { + IColumn column = block[0]; + matched = column is IDateTimeColumn; + + var instants = (IDateTimeColumn)column; + scale = instants.Scale; + first = instants.GetDateTimeOffset(0); + firstRaw = ((IColumn)column).Values[0]; + } + + Assert.Multiple(() => + { + Assert.That(matched, Is.True); + Assert.That(scale, Is.EqualTo(3), "the scale of DateTime64(3), which says what unit the raw count is in"); + Assert.That(first.Offset, Is.EqualTo(TimeSpan.Zero)); + Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss.fff"), Is.EqualTo("2024-06-15 14:00:00.125")); + Assert.That(firstRaw, Is.EqualTo(first.ToUnixTimeMilliseconds()), "scale 3 means the count is milliseconds"); + }); + } + + [Test] + public async Task StreamAsync_TimeColumn_ExposesOffsetsFromMidnightThroughITimeColumn() + { + // Time names a time of day, not an instant, so it carries no timezone and converts to a TimeSpan. + await using var client = TcpServerFixture.CreateClient(); + + bool matchedTime = false; + bool matchedDateTime = false; + int scale = -1; + var spans = Array.Empty(); + TimeSpan first = default; + + await foreach (Block block in client.StreamAsync( + "SELECT toTime('14:30:05') + number FROM system.numbers LIMIT 2", + cancellationToken: None)) + { + IColumn column = block[0]; + matchedTime = column is ITimeColumn; + matchedDateTime = column is IDateTimeColumn; + + var times = (ITimeColumn)column; + scale = times.Scale; + spans = times.ToTimeSpans(); + first = times.GetTimeSpan(0); + } + + Assert.Multiple(() => + { + Assert.That(matchedTime, Is.True); + Assert.That(matchedDateTime, Is.False, "a time of day is not an instant, so it offers no timezone"); + Assert.That(scale, Is.EqualTo(0), "Time counts whole seconds"); + Assert.That(spans, Has.Length.EqualTo(2)); + Assert.That(first, Is.EqualTo(new TimeSpan(14, 30, 5))); + Assert.That(spans[1] - spans[0], Is.EqualTo(TimeSpan.FromSeconds(1))); + }); + } } diff --git a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt index cee2ae4b4..43f05f586 100644 --- a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt +++ b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt @@ -308,6 +308,11 @@ ClickHouse.Driver.Tcp.IColumn.TypeName.get -> string ClickHouse.Driver.Tcp.IColumn ClickHouse.Driver.Tcp.IColumn.this[int row].get -> T ClickHouse.Driver.Tcp.IColumn.Values.get -> System.ReadOnlySpan +ClickHouse.Driver.Tcp.IDateTimeColumn +ClickHouse.Driver.Tcp.IDateTimeColumn.GetDateTimeOffset(int row) -> System.DateTimeOffset +ClickHouse.Driver.Tcp.IDateTimeColumn.Scale.get -> int +ClickHouse.Driver.Tcp.IDateTimeColumn.TimeZone.get -> System.TimeZoneInfo +ClickHouse.Driver.Tcp.IDateTimeColumn.ToDateTimeOffsets() -> System.DateTimeOffset[] ClickHouse.Driver.Tcp.IDynamicColumn ClickHouse.Driver.Tcp.IDynamicColumn.Discriminators.get -> System.ReadOnlySpan ClickHouse.Driver.Tcp.IDynamicColumn.GetTypeColumn(int discriminator) -> ClickHouse.Driver.Tcp.IColumn @@ -347,6 +352,10 @@ ClickHouse.Driver.Tcp.IQBitColumn.GetPlane(int bit) -> System.ReadOnlySpan ClickHouse.Driver.Tcp.IQBitColumn.GetPlane(int bit, int group) -> System.ReadOnlySpan ClickHouse.Driver.Tcp.IQBitColumn.GroupCount.get -> int ClickHouse.Driver.Tcp.IQBitColumn.Stride.get -> int +ClickHouse.Driver.Tcp.ITimeColumn +ClickHouse.Driver.Tcp.ITimeColumn.GetTimeSpan(int row) -> System.TimeSpan +ClickHouse.Driver.Tcp.ITimeColumn.Scale.get -> int +ClickHouse.Driver.Tcp.ITimeColumn.ToTimeSpans() -> System.TimeSpan[] ClickHouse.Driver.Tcp.ITupleColumn ClickHouse.Driver.Tcp.ITupleColumn.Children.get -> System.Collections.Generic.IReadOnlyList ClickHouse.Driver.Tcp.ITupleColumn.FieldNames.get -> System.Collections.Generic.IReadOnlyList diff --git a/ClickHouse.Driver.Tcp/Types/DateTime64Column.cs b/ClickHouse.Driver.Tcp/Types/DateTime64Column.cs index 79c22ec8f..df09371cd 100644 --- a/ClickHouse.Driver.Tcp/Types/DateTime64Column.cs +++ b/ClickHouse.Driver.Tcp/Types/DateTime64Column.cs @@ -20,7 +20,7 @@ namespace ClickHouse.Driver.Tcp.Types; /// out to retain. /// /// -internal sealed class DateTime64Column : IColumn, IStoredValuesColumn +internal sealed class DateTime64Column : IColumn, IDateTimeColumn, IStoredValuesColumn { private readonly int scale; private readonly TimeZoneInfo timeZone; diff --git a/ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs b/ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs index f7b6bfdab..1af747ad5 100644 --- a/ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs @@ -25,7 +25,7 @@ namespace ClickHouse.Driver.Tcp.Types; /// out to retain. /// /// -internal sealed class DateTimeColumn : IColumn, IStoredValuesColumn +internal sealed class DateTimeColumn : IColumn, IDateTimeColumn, IStoredValuesColumn { private readonly TimeZoneInfo timeZone; private readonly int length; @@ -62,6 +62,9 @@ public DateTimeColumn(string name, string typeName, TimeZoneInfo timeZone, byte[ /// interpret the raw seconds. public TimeZoneInfo TimeZone => timeZone; + /// Zero: DateTime counts whole seconds. DateTime64(scale) is where this varies. + public int Scale => 0; + /// The raw epoch-second counts, as a zero-copy view. Use for a /// calendar view presented in the column's timezone. public ReadOnlySpan Values => MemoryMarshal.Cast(buffer.AsSpan(0, length)); diff --git a/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs b/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs new file mode 100644 index 000000000..94a9a4d9b --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs @@ -0,0 +1,53 @@ +using System; + +namespace ClickHouse.Driver.Tcp; + +/// +/// The calendar read surface of a decoded DateTime or DateTime64(scale) column. Both name an +/// instant, and both are stored as a plain integer count: seconds since the Unix epoch for DateTime, and +/// units of 10^- seconds for DateTime64. +/// +/// +/// That count is what the default view exposes — IColumn<uint> for +/// DateTime, IColumn<long> for DateTime64 — because it is the layout the wire +/// carried, and reading it costs no conversion. Turning a count into an instant needs the column's +/// , which the column header declares and which nothing on reports, +/// so this interface exists to make that conversion possible without leaving the block tier. +/// +/// +/// +/// Obtain it by pattern-matching a column, e.g. if (column is IDateTimeColumn instants). It is not +/// generic: DateTime and DateTime64 store counts of different widths, and a caller asking for an +/// instant does not care which. +/// +/// +/// +/// A Time or Time64 column names a time of day rather than an instant, has no timezone, and +/// therefore implements instead. +/// +public interface IDateTimeColumn : IColumn +{ + /// + /// The timezone the column's counts are read in, as declared by the column type — the argument of + /// DateTime('Europe/Amsterdam'), or the server's own timezone when the type names none. + /// + TimeZoneInfo TimeZone { get; } + + /// + /// The number of decimal digits of sub-second precision the stored count carries: the scale of + /// DateTime64(scale), and 0 for DateTime, which counts whole seconds. + /// + int Scale { get; } + + /// Reads one row as an instant, offset to . + /// The row index, from 0 to - 1. + /// The instant the row's stored count names. + DateTimeOffset GetDateTimeOffset(int row); + + /// + /// Reads the whole column as instants, offset to . + /// + /// A new array of instants. Unlike the borrowed spans elsewhere on + /// the block tier, this array is the caller's and outlives the block. + DateTimeOffset[] ToDateTimeOffsets(); +} diff --git a/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs b/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs new file mode 100644 index 000000000..ee7c7697d --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs @@ -0,0 +1,45 @@ +using System; + +namespace ClickHouse.Driver.Tcp; + +/// +/// The calendar read surface of a decoded Time or Time64(scale) column. Both name a time of day +/// rather than an instant, and both are stored as a signed integer count from midnight: seconds for +/// Time, and units of 10^- seconds for Time64. +/// +/// +/// That count is what the default view exposes — IColumn<int> for +/// Time, IColumn<long> for Time64 — because it is the layout the wire carried. This +/// interface converts it, so a caller does not have to know which unit a given column counts in. +/// +/// +/// +/// Obtain it by pattern-matching a column, e.g. if (column is ITimeColumn times). +/// +/// +/// +/// The count is signed and ClickHouse does not clamp it to one day, so a value outside +/// 00:00:0023:59:59 is representable and surfaces as a that is negative or +/// longer than a day. A DateTime or DateTime64 column names an instant instead, and implements +/// . +/// +public interface ITimeColumn : IColumn +{ + /// + /// The number of decimal digits of sub-second precision the stored count carries: the scale of + /// Time64(scale), and 0 for Time, which counts whole seconds. + /// + int Scale { get; } + + /// Reads one row as an offset from midnight. + /// The row index, from 0 to - 1. + /// The offset from midnight the row's stored count names. + TimeSpan GetTimeSpan(int row); + + /// + /// Reads the whole column as offsets from midnight. + /// + /// A new array of offsets. Unlike the borrowed spans elsewhere on + /// the block tier, this array is the caller's and outlives the block. + TimeSpan[] ToTimeSpans(); +} diff --git a/ClickHouse.Driver.Tcp/Types/Time64Column.cs b/ClickHouse.Driver.Tcp/Types/Time64Column.cs index 0c2124697..71d26384e 100644 --- a/ClickHouse.Driver.Tcp/Types/Time64Column.cs +++ b/ClickHouse.Driver.Tcp/Types/Time64Column.cs @@ -26,7 +26,7 @@ namespace ClickHouse.Driver.Tcp.Types; /// out to retain. /// /// -internal sealed class Time64Column : IColumn, IStoredValuesColumn +internal sealed class Time64Column : IColumn, ITimeColumn, IStoredValuesColumn { private readonly int scale; private readonly int length; diff --git a/ClickHouse.Driver.Tcp/Types/TimeColumn.cs b/ClickHouse.Driver.Tcp/Types/TimeColumn.cs index 7c5b76149..6e496e57e 100644 --- a/ClickHouse.Driver.Tcp/Types/TimeColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/TimeColumn.cs @@ -23,7 +23,7 @@ namespace ClickHouse.Driver.Tcp.Types; /// out to retain. /// /// -internal sealed class TimeColumn : IColumn, IStoredValuesColumn +internal sealed class TimeColumn : IColumn, ITimeColumn, IStoredValuesColumn { private readonly int length; private readonly bool pooled; @@ -53,6 +53,9 @@ public TimeColumn(string name, string typeName, byte[] buffer, int length, bool /// public int RowCount => length / sizeof(int); + /// Zero: Time counts whole seconds. Time64(scale) is where this varies. + public int Scale => 0; + /// The raw signed second counts, as a zero-copy view. Use for a /// view. public ReadOnlySpan Values => MemoryMarshal.Cast(buffer.AsSpan(0, length)); From 2651d2145698b64a8a3446eb2a989767a192c2c5 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sat, 29 Aug 2026 16:58:25 +0200 Subject: [PATCH 5/6] Gate the Time read test and correct the temporal projections' contract The ITimeColumn test queried Time without enabling it. On 25.8, the floor of the CI matrix, the type is setting-gated, so the test would have failed there while passing on a local 26.6 where it has graduated. Every Time case in InsertRoundTripCase already passes both flags. With them set, toTime resolves to toTimeWithFixedDate, which takes a Date or DateTime rather than a String, so the query builds its value with a cast instead. Two calendar assertions formatted without a culture, so a non-Gregorian current culture would render a different year and fail on correct values. IDateTimeColumn.TimeZone claimed a type naming no timezone resolves to the server's. A query's session_timezone comes first. The projections also count 100 ns ticks, so scale 8 and 9 truncate; the concrete columns document that and the interfaces a consumer actually sees did not. Found by a codex review of 0d099f64. Co-Authored-By: Claude Opus 5 (1M context) --- .../ColumnarReadSurfaceIntegrationTests.cs | 22 +++++++++++++++---- .../Types/IDateTimeColumn.cs | 15 ++++++++++--- ClickHouse.Driver.Tcp/Types/ITimeColumn.cs | 9 +++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs index 50375bbea..241e3f9f2 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -824,7 +825,7 @@ public async Task StreamAsync_DateTimeColumn_ExposesTheDeclaredTimezoneAndInstan Assert.That(offsets, Has.Length.EqualTo(3)); Assert.That(first, Is.EqualTo(offsets[0]), "the per-row and whole-column reads agree"); Assert.That(first.Offset, Is.EqualTo(TimeSpan.FromHours(2)), "June is CEST, so +02:00"); - Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss"), Is.EqualTo("2024-06-15 14:00:00")); + Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), Is.EqualTo("2024-06-15 14:00:00")); Assert.That(first.ToUnixTimeSeconds(), Is.EqualTo(firstRaw), "the instant is the raw count, presented"); Assert.That(offsets[2] - offsets[0], Is.EqualTo(TimeSpan.FromSeconds(2))); }); @@ -858,7 +859,7 @@ public async Task StreamAsync_DateTime64Column_ReportsItsScaleAndSubSecondPrecis Assert.That(matched, Is.True); Assert.That(scale, Is.EqualTo(3), "the scale of DateTime64(3), which says what unit the raw count is in"); Assert.That(first.Offset, Is.EqualTo(TimeSpan.Zero)); - Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss.fff"), Is.EqualTo("2024-06-15 14:00:00.125")); + Assert.That(first.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), Is.EqualTo("2024-06-15 14:00:00.125")); Assert.That(firstRaw, Is.EqualTo(first.ToUnixTimeMilliseconds()), "scale 3 means the count is milliseconds"); }); } @@ -875,9 +876,22 @@ public async Task StreamAsync_TimeColumn_ExposesOffsetsFromMidnightThroughITimeC var spans = Array.Empty(); TimeSpan first = default; + // Time and Time64 are setting-gated on 25.8, the floor of the CI matrix, so the query needs both flags. + var options = new ClickHouseTcpQueryOptions + { + Settings = new Dictionary(StringComparer.Ordinal) + { + ["enable_time_time64_type"] = "1", + ["allow_experimental_time_time64_type"] = "1", + }, + }; + + // A cast, not toTime(): with the flags above set, toTime resolves to toTimeWithFixedDate, which takes a + // Date or DateTime and rejects a String. await foreach (Block block in client.StreamAsync( - "SELECT toTime('14:30:05') + number FROM system.numbers LIMIT 2", - cancellationToken: None)) + "SELECT '14:30:05'::Time + number FROM system.numbers LIMIT 2", + options, + None)) { IColumn column = block[0]; matchedTime = column is ITimeColumn; diff --git a/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs b/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs index 94a9a4d9b..745b44629 100644 --- a/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs @@ -28,8 +28,10 @@ namespace ClickHouse.Driver.Tcp; public interface IDateTimeColumn : IColumn { /// - /// The timezone the column's counts are read in, as declared by the column type — the argument of - /// DateTime('Europe/Amsterdam'), or the server's own timezone when the type names none. + /// The timezone the column's counts are read in. The column type's own argument wins — the + /// Europe/Amsterdam of DateTime('Europe/Amsterdam'). A type that names none resolves to the + /// query's session_timezone setting if it has one, and to the timezone the handshake reported + /// otherwise. /// TimeZoneInfo TimeZone { get; } @@ -40,13 +42,20 @@ public interface IDateTimeColumn : IColumn int Scale { get; } /// Reads one row as an instant, offset to . + /// + /// counts 100 ns ticks, so this is lossy above 7: a + /// DateTime64(8) or DateTime64(9) column has its sub-100 ns digits truncated toward zero. The + /// exact count stays in the view. + /// /// The row index, from 0 to - 1. - /// The instant the row's stored count names. + /// The instant the row's stored count names, to 100 ns. DateTimeOffset GetDateTimeOffset(int row); /// /// Reads the whole column as instants, offset to . /// + /// Lossy above 7, for the reason given on + /// . /// A new array of instants. Unlike the borrowed spans elsewhere on /// the block tier, this array is the caller's and outlives the block. DateTimeOffset[] ToDateTimeOffsets(); diff --git a/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs b/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs index ee7c7697d..f612bdd0f 100644 --- a/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/ITimeColumn.cs @@ -32,13 +32,20 @@ public interface ITimeColumn : IColumn int Scale { get; } /// Reads one row as an offset from midnight. + /// + /// counts 100 ns ticks, so this is lossy above 7: a + /// Time64(8) or Time64(9) column has its sub-100 ns digits truncated toward zero. The exact + /// count stays in the view. + /// /// The row index, from 0 to - 1. - /// The offset from midnight the row's stored count names. + /// The offset from midnight the row's stored count names, to 100 ns. TimeSpan GetTimeSpan(int row); /// /// Reads the whole column as offsets from midnight. /// + /// Lossy above 7, for the reason given on + /// . /// A new array of offsets. Unlike the borrowed spans elsewhere on /// the block tier, this array is the caller's and outlives the block. TimeSpan[] ToTimeSpans(); From b9dd50ef7fc6f9a80d388e05c3e32d5dff9325b0 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Mon, 31 Aug 2026 17:16:56 +0200 Subject: [PATCH 6/6] Close the C-F public-API findings from the native examples Writing the 30 native examples exercised the frozen surface as a consumer and found 46 items. These are sections C through F, less the ones marked do-not-fix. Behaviour: - ClickHouseTcpDecimal.ToString(format, provider) rejects a format it cannot render instead of returning differently formatted text than was asked for. - ITupleColumn.FieldNames is empty, not null, for an unnamed tuple. The server refuses a partly-named tuple, so a non-empty list holds no nulls. - The columnar write mismatch names the CLR element type supplied and the types the target accepts, matching the row tier, rather than an internal class. - A null Array(T) row reports the column argument, not an internal local. - ClickHouseTcpServerException.Message drops the class-name prefix the server repeats from its name field. - ClickHouseErrorCode names code 26, CANNOT_PARSE_QUOTED_STRING. - The client generates a query id when the caller names none. The native protocol never sends the server's own id back, so an operation that left the field empty could not afterwards be found in system.query_log; the id in force reaches every log line and the trace span. Surface: - ClickHouseTcpInsertOptions.DeduplicationToken carries insert_deduplication_token, which is what makes a retried insert safe. - ClickHouseTcpQueryCallbacks.OnBlockWritten reports each block an insert sends with its rows and its size before and after compression. A native insert gets no Progress packets, so this is its only progress, and the only place MaxRowsPerBlock is observable. - ClickHouseTcpServerInfo separates the advertised, negotiated and client protocol revisions; the connection log line says which one is in force. - IClickHouseTcpDataSource, registered alongside the concrete data source. - ClickHouseTcpClientOptions.ResolvedPort is public. Docs, each measured against 26.6.1.1193: - Compressor governs what an insert writes, not what the server sends: the server frames with its own network_compression_method (1,630,538 bytes of result under lz4 against 742,764 under zstd, client codec fixed), and that setting leaves the insert size untouched. - The statement's column list picks the inserted subset, not the columns built. - QBit(Int8, N) needs 26.7, and a strided QBit cannot be reached below it. - ProfileInfo.Bytes is the server's in-memory measure: 464 bytes for ten rows whose uncompressed body is 90. - OnProgress fires on the server's interactive_delay, default 100,000 us. Co-Authored-By: Claude Opus 5 (1M context) --- .../Client/ClickHouseTcpClientOptionsTests.cs | 4 +- ...ouseTcpServiceCollectionExtensionsTests.cs | 7 + .../ClickHouseTcpServerExceptionTests.cs | 20 +++ .../ClickHouseTcpCallbackIntegrationTests.cs | 144 ++++++++++++++++++ .../ClickHouseTcpClientIntegrationTests.cs | 82 ++++++++++ ...ouseTcpConnectionInsertIntegrationTests.cs | 11 +- .../ClickHouseTcpExceptionIntegrationTests.cs | 6 + .../ClickHouseTcpLoggingIntegrationTests.cs | 53 ++++++- .../ColumnarReadSurfaceIntegrationTests.cs | 16 +- .../PublicSurfaceIntegrationTests.cs | 9 ++ .../Numerics/ClickHouseTcpDecimalTests.cs | 37 +++++ .../Types/ArrayColumnCodecTests.cs | 6 +- .../Types/TupleColumnCodecTests.cs | 18 +++ .../Client/ClickHouseTcpBlockWritten.cs | 58 +++++++ .../Client/ClickHouseTcpClient.cs | 77 ++++++++-- .../Client/ClickHouseTcpClientOptions.cs | 32 ++-- .../Client/ClickHouseTcpDataSource.cs | 6 +- .../Client/ClickHouseTcpInsertOptions.cs | 29 ++++ .../Client/ClickHouseTcpProfileInfo.cs | 11 +- .../Client/ClickHouseTcpQueryCallbacks.cs | 22 +++ .../Client/ClickHouseTcpQueryOptions.cs | 19 ++- .../Client/ClickHouseTcpServerInfo.cs | 22 ++- .../Client/IClickHouseTcpDataSource.cs | 49 ++++++ .../Client/IClickHouseTcpOperations.cs | 7 +- .../Client/IConnectionFactory.cs | 1 + ...lickHouseTcpServiceCollectionExtensions.cs | 11 +- .../Diagnostic/ClientOperation.cs | 2 +- .../Exceptions/ClickHouseErrorCode.cs | 6 + .../ClickHouseTcpServerException.cs | 30 +++- .../ClickHouseTcpTransportException.cs | 6 + .../Logging/ConnectionLog.cs | 6 +- .../Numerics/ClickHouseTcpDecimal.cs | 31 +++- .../Protocol/ClickHouseBinaryWriter.cs | 9 ++ .../Protocol/ClickHouseTcpConnection.cs | 82 +++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 29 +++- .../Types/ClickHouseTcpColumn.cs | 16 +- .../Types/Codecs/IArrayWriteShape.cs | 2 +- ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 24 ++- ClickHouse.Driver.Tcp/Types/ITupleColumn.cs | 10 +- ClickHouse.Driver.Tcp/Types/TupleColumn.cs | 5 +- 40 files changed, 929 insertions(+), 86 deletions(-) create mode 100644 ClickHouse.Driver.Tcp/Client/ClickHouseTcpBlockWritten.cs create mode 100644 ClickHouse.Driver.Tcp/Client/IClickHouseTcpDataSource.cs diff --git a/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs b/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs index c4b6b6442..47f6d49d6 100644 --- a/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs @@ -577,7 +577,9 @@ public void With_ChangingOneProperty_CarriesEveryOtherPropertyAcross() foreach (PropertyInfo property in typeof(ClickHouseTcpClientOptions).GetProperties()) { - if (property.Name == nameof(ClickHouseTcpClientOptions.Port)) + // The property under change, and any property computed from it: ResolvedPort is derived from + // Port and UseTls, so it is meant to move when either does. Only stored state is carried across. + if (property.Name == nameof(ClickHouseTcpClientOptions.Port) || property.SetMethod is null) { continue; } diff --git a/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs b/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs index 1a0c121b0..c9e5fbfb1 100644 --- a/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/DependencyInjection/ClickHouseTcpServiceCollectionExtensionsTests.cs @@ -47,6 +47,7 @@ public void AddClickHouseTcpDataSource_WithOptions_RegistersOnlySingletons() Is.EquivalentTo(new[] { (typeof(ClickHouseTcpDataSource), ServiceLifetime.Singleton), + (typeof(IClickHouseTcpDataSource), ServiceLifetime.Singleton), (typeof(IClickHouseTcpClient), ServiceLifetime.Singleton), (typeof(IClickHouseTcpOperations), ServiceLifetime.Singleton), })); @@ -66,6 +67,10 @@ public async Task AddClickHouseTcpDataSource_WithOptions_ResolvesTheClientTheDat Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource)); Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource.GetClient())); Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource.GetClient())); + + // The interface forwards to the same instance, so injecting either one gets the owner of the pool + // rather than a second data source the provider would also dispose. + Assert.That(provider.GetRequiredService(), Is.SameAs(dataSource)); }); } @@ -144,9 +149,11 @@ public async Task AddClickHouseTcpDataSource_WithAServiceKey_RegistersKeyedServi Assert.Multiple(() => { + Assert.That(provider.GetRequiredKeyedService("reporting"), Is.SameAs(dataSource)); Assert.That(provider.GetRequiredKeyedService("reporting"), Is.SameAs(dataSource.GetClient())); Assert.That(provider.GetRequiredKeyedService("reporting"), Is.SameAs(dataSource.GetClient())); Assert.That(provider.GetService(), Is.Null); + Assert.That(provider.GetService(), Is.Null); Assert.That(provider.GetService(), Is.Null); }); } diff --git a/ClickHouse.Driver.Tcp.Tests/Exceptions/ClickHouseTcpServerExceptionTests.cs b/ClickHouse.Driver.Tcp.Tests/Exceptions/ClickHouseTcpServerExceptionTests.cs index 35637d95e..9e84c86cb 100644 --- a/ClickHouse.Driver.Tcp.Tests/Exceptions/ClickHouseTcpServerExceptionTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Exceptions/ClickHouseTcpServerExceptionTests.cs @@ -20,4 +20,24 @@ public void Code_ServerSentACodeThisClientDoesNotName_ReadsAsUnknownWithTheRawVa Assert.That(exception.ErrorCode, Is.EqualTo(65000)); }); } + + // The shapes a live server does not send: a message without the prefix, one that only looks like it carries + // it, and an empty name. That the real prefix is present at all is asserted in the integration suite. + [TestCase("DB::Exception", "DB::Exception: it failed", "it failed")] + [TestCase("DB::Exception", "it failed", "it failed")] + [TestCase("DB::Exception", "DB::Exception", "DB::Exception")] + [TestCase("DB::Exception", "DB::ExceptionX: it failed", "DB::ExceptionX: it failed")] + [TestCase("DB::NetException", "DB::Exception: it failed", "DB::Exception: it failed")] + [TestCase("", "DB::Exception: it failed", "DB::Exception: it failed")] + [TestCase(null, "DB::Exception: it failed", "DB::Exception: it failed")] + public void Message_ServerRepeatedTheNameAtTheHeadOfTheText_ReportsTheTextWithoutIt(string name, string sent, string expected) + { + var exception = new ClickHouseTcpServerException(60, name, sent, "stack trace"); + + Assert.Multiple(() => + { + Assert.That(exception.Message, Is.EqualTo(expected)); + Assert.That(exception.Name, Is.EqualTo(name), "the class name is still reported, just not twice."); + }); + } } diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCallbackIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCallbackIntegrationTests.cs index ca92f27a5..0a56cceec 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCallbackIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCallbackIntegrationTests.cs @@ -314,6 +314,150 @@ await client.InsertAsync( } } + [TestCase("none")] + [TestCase("lz4")] + [TestCase("zstd")] + public async Task InsertAsync_OnBlockWritten_ReportsEachBlockWithItsRowsAndBothByteCounts(string compression) + { + // An insert gets no Progress packets at all, so this callback is its only progress — and the only place + // MaxRowsPerBlock becomes observable. Run over each codec because the two byte counts have to agree + // exactly when nothing compresses and differ when something does. + await using ClickHouseTcpClient client = new(TcpServerFixture.Options() with { Compressor = ClickHouseTcpClientOptions.ResolveCompressor(compression) }); + string table = UniqueTableName(); + await client.ExecuteAsync($"CREATE TABLE {table} (id UInt64, s String) ENGINE = Memory", cancellationToken: None); + + try + { + const int rows = 2500; + var ids = new ulong[rows]; + var text = new string[rows]; + for (int i = 0; i < rows; i++) + { + ids[i] = (ulong)i; + text[i] = "value-" + i; + } + + var seen = new List(); + await client.InsertAsync( + $"INSERT INTO {table} (id, s) VALUES", + new IColumn[] { ClickHouseTcpColumn.Create("id", ids), ClickHouseTcpColumn.Create("s", text) }, + new ClickHouseTcpInsertOptions + { + MaxRowsPerBlock = 1000, + Callbacks = new ClickHouseTcpQueryCallbacks { OnBlockWritten = b => seen.Add(b) }, + }, + None); + + var stored = (ulong)await client.ExecuteScalarAsync($"SELECT count() FROM {table}", cancellationToken: None); + bool compressing = compression != "none"; + + Assert.Multiple(() => + { + Assert.That(stored, Is.EqualTo((ulong)rows), "observing the write did not change what was written"); + Assert.That(seen.Select(b => b.BlockIndex), Is.EqualTo(new[] { 0, 1, 2 }), "zero-based, in send order"); + Assert.That(seen.Select(b => b.RowCount), Is.EqualTo(new[] { 1000, 1000, 500 }), "MaxRowsPerBlock, then the remainder"); + Assert.That(seen.Sum(b => b.RowCount), Is.EqualTo(rows)); + Assert.That(seen.Select(b => b.UncompressedBytes), Is.All.GreaterThan(0)); + + foreach (ClickHouseTcpBlockWritten block in seen) + { + Assert.That( + block.CompressedBytes, + compressing ? Is.LessThan(block.UncompressedBytes) : Is.EqualTo(block.UncompressedBytes), + $"block {block.BlockIndex} under {compression}"); + } + }); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + [Test] + public async Task InsertAsync_OnBlockWritten_ReportsTheSameBodySizeWhicheverCodecFramesIt() + { + // The uncompressed count is the body's own size, so it cannot depend on what compresses it afterwards. + // One assertion across two clients, which no single-client test can make. + var perCompression = new Dictionary(StringComparer.Ordinal); + foreach (string compression in new[] { "none", "lz4", "zstd" }) + { + await using ClickHouseTcpClient client = new(TcpServerFixture.Options() with { Compressor = ClickHouseTcpClientOptions.ResolveCompressor(compression) }); + string table = UniqueTableName(); + await client.ExecuteAsync($"CREATE TABLE {table} (id UInt64) ENGINE = Memory", cancellationToken: None); + + try + { + var ids = new ulong[500]; + for (int i = 0; i < ids.Length; i++) + { + ids[i] = (ulong)i; + } + + long uncompressed = 0; + await client.InsertAsync( + $"INSERT INTO {table} (id) VALUES", + new IColumn[] { ClickHouseTcpColumn.Create("id", ids) }, + new ClickHouseTcpInsertOptions + { + Callbacks = new ClickHouseTcpQueryCallbacks { OnBlockWritten = b => uncompressed += b.UncompressedBytes }, + }, + None); + + perCompression[compression] = uncompressed; + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + Assert.That(perCompression["lz4"], Is.EqualTo(perCompression["none"])); + Assert.That(perCompression["zstd"], Is.EqualTo(perCompression["none"])); + } + + [Test] + public async Task InsertAsync_ZeroRows_ReportsNoWrittenBlock() + { + // Zero rows sends only the terminator, which is not a block of the caller's rows. + await using ClickHouseTcpClient client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + await client.ExecuteAsync($"CREATE TABLE {table} (id Int32) ENGINE = Memory", cancellationToken: None); + + try + { + var seen = new List(); + await client.InsertAsync( + $"INSERT INTO {table} (id) VALUES", + new IColumn[] { ClickHouseTcpColumn.Create("id", Array.Empty()) }, + new ClickHouseTcpInsertOptions + { + Callbacks = new ClickHouseTcpQueryCallbacks { OnBlockWritten = b => seen.Add(b) }, + }, + None); + + Assert.That(seen, Is.Empty); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}", cancellationToken: None); + } + } + + [Test] + public async Task StreamAsync_OnBlockWritten_IsNeverCalledForAQuery() + { + await using ClickHouseTcpClient client = TcpServerFixture.CreateClient(); + + var seen = new List(); + await DrainAsync( + client, + "SELECT number FROM numbers(1000)", + new ClickHouseTcpQueryOptions { Callbacks = new ClickHouseTcpQueryCallbacks { OnBlockWritten = b => seen.Add(b) } }); + + Assert.That(seen, Is.Empty, "a query sends no blocks"); + } + [Test] public async Task StreamAsync_ThrowingCallback_PropagatesAndLeavesTheClientUsable() { diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientIntegrationTests.cs index b09466898..dc2882978 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientIntegrationTests.cs @@ -19,6 +19,9 @@ public class ClickHouseTcpClientIntegrationTests private static string UniqueTableName() => $"tcp_client_test_{Guid.NewGuid():N}"; + private static async Task CountAsync(ClickHouseTcpClient client, string table) + => (ulong)await client.ExecuteScalarAsync($"SELECT count() FROM {table}", cancellationToken: None); + private sealed class IdRow { public ulong Id { get; set; } @@ -265,6 +268,85 @@ public async Task InsertAsync_ZeroRows_IsNoOp() } } + [Test] + public async Task InsertAsync_SameDeduplicationToken_AppliesTheRowsOnlyOnce() + { + // Why the option exists: a ClickHouseTcpTransportException leaves an insert's outcome unknown, so a retry + // needs the server to recognise the second attempt. Asserted against a real server because the whole + // behaviour is the server's, and it needs the deduplication window switched on for a non-replicated table. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + try + { + await client.ExecuteAsync( + $"CREATE TABLE {table} (id Int32) ENGINE = MergeTree ORDER BY id SETTINGS non_replicated_deduplication_window = 100"); + + async Task InsertAsync(string token) => await client.InsertAsync( + $"INSERT INTO {table} (id) VALUES", + new IColumn[] { PrimitiveColumn.FromValues("id", "Int32", new[] { 1, 2, 3 }) }, + new ClickHouseTcpInsertOptions { DeduplicationToken = token }); + + await InsertAsync("batch-1"); + ulong afterFirst = await CountAsync(client, table); + + await InsertAsync("batch-1"); + ulong afterRetry = await CountAsync(client, table); + + await InsertAsync("batch-2"); + ulong afterNewToken = await CountAsync(client, table); + + // No token: nothing recognises the repeat, which is the behaviour the token exists to change. + await client.InsertAsync( + $"INSERT INTO {table} (id) VALUES", + new IColumn[] { PrimitiveColumn.FromValues("id", "Int32", new[] { 1, 2, 3 }) }); + ulong afterUntokenized = await CountAsync(client, table); + + Assert.Multiple(() => + { + Assert.That(afterFirst, Is.EqualTo(3UL)); + Assert.That(afterRetry, Is.EqualTo(3UL), "a repeat under the same token is dropped, so a retry is safe"); + Assert.That(afterNewToken, Is.EqualTo(6UL), "a new token is a new batch"); + Assert.That(afterUntokenized, Is.EqualTo(9UL), "without a token the same rows land twice"); + }); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}"); + } + } + + [Test] + public async Task InsertAsync_DeduplicationTokenAndTheSameSettingByName_PrefersTheProperty() + { + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + try + { + await client.ExecuteAsync( + $"CREATE TABLE {table} (id Int32) ENGINE = MergeTree ORDER BY id SETTINGS non_replicated_deduplication_window = 100"); + + // The property and the raw setting disagree. The property wins, so the second insert — whose property + // repeats the first's token — is dropped even though its raw setting differs. + async Task InsertAsync(string property, string setting) => await client.InsertAsync( + $"INSERT INTO {table} (id) VALUES", + new IColumn[] { PrimitiveColumn.FromValues("id", "Int32", new[] { 1, 2, 3 }) }, + new ClickHouseTcpInsertOptions + { + DeduplicationToken = property, + Settings = new Dictionary { ["insert_deduplication_token"] = setting }, + }); + + await InsertAsync("wins", "loses-a"); + await InsertAsync("wins", "loses-b"); + + Assert.That(await CountAsync(client, table), Is.EqualTo(3UL)); + } + finally + { + await client.ExecuteAsync($"DROP TABLE IF EXISTS {table}"); + } + } + [Test] public async Task InsertAsync_SchemaMismatch_ThrowsArgumentExceptionAndClientUsable() { diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionInsertIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionInsertIntegrationTests.cs index dd5ca9f18..c8b2abd88 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionInsertIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionInsertIntegrationTests.cs @@ -235,7 +235,16 @@ public async Task InsertAsync_ColumnClrTypeNotWritableAsTarget_ThrowsThenConnect var thrown = Assert.ThrowsAsync(async () => await connection.InsertAsync($"INSERT INTO {table} (value) VALUES", new[] { mismatched }, cancellationToken: None)); - Assert.That(thrown.Message, Does.Contain("Int32")); + + // The message names the target, the CLR element type it was given, and what it accepts — not the + // driver's internal column class, which tells the caller nothing they wrote. + Assert.Multiple(() => + { + Assert.That(thrown.Message, Does.Contain("'value' (Int32)")); + Assert.That(thrown.Message, Does.Contain("System.Int64"), "the element type of the column supplied."); + Assert.That(thrown.Message, Does.Contain("It accepts System.Int32")); + Assert.That(thrown.Message, Does.Not.Contain(nameof(PrimitiveColumn)), "the internal column class is not named."); + }); // Only the terminator went out (no data block), so the server saw an insert of no rows and the // connection is left ready and usable — and nothing was actually inserted. diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpExceptionIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpExceptionIntegrationTests.cs index 6a4973e8f..fb988ddb3 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpExceptionIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpExceptionIntegrationTests.cs @@ -18,6 +18,7 @@ public class ClickHouseTcpExceptionIntegrationTests [TestCase("SELECT * FROM system.tables WHERE", ClickHouseErrorCode.SyntaxError, 62)] [TestCase("SELECT notAFunction123(1)", ClickHouseErrorCode.UnknownFunction, 46)] [TestCase("SELECT * FROM db_that_does_not_exist_xyz.t", ClickHouseErrorCode.UnknownDatabase, 81)] + [TestCase("SELECT CAST('(a, 1)', 'Tuple(String, UInt8)')", ClickHouseErrorCode.CannotParseQuotedString, 26)] public async Task ExecuteAsync_ServerRejectsTheQuery_MapsTheCodeToItsNamedConstant( string sql, ClickHouseErrorCode expected, @@ -34,6 +35,11 @@ public async Task ExecuteAsync_ServerRejectsTheQuery_MapsTheCodeToItsNamedConsta Assert.That(thrown.RawCode, Is.EqualTo(expectedRaw)); Assert.That(thrown.ErrorCode, Is.EqualTo(expectedRaw), "DbException.ErrorCode carries the same number."); Assert.That(thrown.Name, Is.Not.Empty); + + // The server writes its class name into the message as well as into the name field. Only a real + // server proves the prefix is there to strip. + Assert.That(thrown.Message, Does.Not.StartWith(thrown.Name), "Name is not repeated at the head of Message."); + Assert.That(thrown.Message, Is.Not.Empty, "stripping the prefix leaves the message text."); }); } diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpLoggingIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpLoggingIntegrationTests.cs index 054661413..d3d99ee53 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpLoggingIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpLoggingIntegrationTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using ClickHouse.Driver.Tcp.Format; @@ -54,7 +55,10 @@ public async Task StreamAsync_WithALoggerFactory_LogsTheHandshakeResult() Assert.Multiple(() => { Assert.That(ConnectionLogger.WithEventId(2000), Is.Not.Empty, "the dial is announced before it is attempted"); - Assert.That(ConnectionLogger.WithEventId(2001).Single().Message, Does.Contain("protocol revision"), "the negotiated revision is worth having in a log"); + // Both revisions, each labelled, so a reader can tell the one in force from the one advertised. + string opened = ConnectionLogger.WithEventId(2001).Single().Message; + Assert.That(opened, Does.Contain($"protocol revision {NegotiatedProtocol.ClientTcpProtocolVersion} in force")); + Assert.That(opened, Does.Match(@"server advertised \d+")); }); } @@ -78,6 +82,53 @@ public async Task StreamAsync_WithALoggerFactory_LogsTheStatementAndItsCounters( }); } + [Test] + public async Task StreamAsync_CallerNamedNoQueryId_LogsAGeneratedIdTheServerRecordedUnder() + { + // The native protocol never sends back the id a server assigns, so the only id that can be both logged + // and looked up afterwards is one the client chose. Proving it needs both ends: the id in the log line, + // and the same id against a row in system.query_log. + await using ClickHouseTcpClient client = CreateClient(); + + await DrainAsync(client, "SELECT sum(number) FROM numbers(1000)"); + + // Both lines are read before the lookup: it runs its own queries on this client, which log 1000/1001 too. + LogEntry started = ClientLogger.WithEventId(1000).Single(); + string completed = ClientLogger.WithEventId(1001).Single().Message; + + Match logged = Regex.Match(started.Message, @"query id ([0-9a-f-]{36})"); + Assert.That(logged.Success, Is.True, $"a generated id is logged, not an empty field: {started.Message}"); + + string id = logged.Groups[1].Value; + object recorded = await QueryLog.ScalarAsync( + client, + $"SELECT count() FROM system.query_log WHERE query_id = '{id}' AND type = 'QueryFinish'"); + + Assert.Multiple(() => + { + Assert.That(Guid.TryParse(id, out _), Is.True, "the generated id is a GUID, the shape the server uses too"); + Assert.That(recorded, Is.EqualTo(1UL), "the id the client logged is the one the server ran the query under"); + Assert.That(completed, Does.Contain(id), "the completion line carries the same id"); + }); + } + + [Test] + public async Task StreamAsync_CallerSuppliedAQueryId_LogsThatIdRatherThanAGeneratedOne() + { + await using ClickHouseTcpClient client = CreateClient(); + string supplied = "test-" + Guid.NewGuid().ToString("N"); + + await foreach (Block block in client.StreamAsync( + "SELECT 1", + new ClickHouseTcpQueryOptions { QueryId = supplied }, + cancellationToken: None)) + { + _ = block.RowCount; + } + + Assert.That(ClientLogger.WithEventId(1000).Single().Message, Does.Contain($"query id {supplied}")); + } + [Test] public async Task StreamAsync_ServerRejectsTheStatement_LogsTheFailureAtError() { diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs index 241e3f9f2..b3ee50806 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs @@ -252,7 +252,7 @@ public async Task StreamAsync_NamedTupleColumn_ExposesPerElementChildColumnsThro var tuple = (ITupleColumn)column; childCount = tuple.Children.Count; - fieldNames = tuple.FieldNames?.ToArray(); + fieldNames = tuple.FieldNames.ToArray(); childRowCounts = new[] { tuple.Children[0].RowCount, tuple.Children[1].RowCount }; firstElement = ((IColumn)tuple.Children[0]).Values.ToArray(); secondElement = ((IColumn)tuple.Children[1]).Values.ToArray(); @@ -272,14 +272,14 @@ public async Task StreamAsync_NamedTupleColumn_ExposesPerElementChildColumnsThro } [Test] - public async Task StreamAsync_UnnamedTupleWithCompositeElement_OmitsFieldNamesAndAllowsChildRecursion() + public async Task StreamAsync_UnnamedTupleWithCompositeElement_ReportsEmptyFieldNamesAndAllowsChildRecursion() { - // Two things the named case cannot show: an unnamed tuple carries no names at all (FieldNames is null, not - // a list of nulls), and a child that is itself a composite pattern-matches to its own columnar view — so a + // Two things the named case cannot show: an unnamed tuple reports an empty FieldNames rather than null or a + // list of nulls, and a child that is itself a composite pattern-matches to its own columnar view — so a // Tuple(Array(Int32), ...) can be walked into without materializing the tuple or the array rows. await using var client = TcpServerFixture.CreateClient(); - bool hasFieldNames = true; + string[] fieldNames = null; bool childIsArray = false; int[] childOffsets = null; int[] childInnerValues = null; @@ -290,7 +290,9 @@ public async Task StreamAsync_UnnamedTupleWithCompositeElement_OmitsFieldNamesAn cancellationToken: None)) { var tuple = (ITupleColumn)block[0]; - hasFieldNames = tuple.FieldNames is not null; + + // Enumerated without a null check, which is the point of the empty list. + fieldNames = tuple.FieldNames.ToArray(); childIsArray = tuple.Children[0] is IArrayColumn; var child = (IArrayColumn)tuple.Children[0]; @@ -300,7 +302,7 @@ public async Task StreamAsync_UnnamedTupleWithCompositeElement_OmitsFieldNamesAn Assert.Multiple(() => { - Assert.That(hasFieldNames, Is.False, "an unnamed tuple reports no names rather than a list of nulls"); + Assert.That(fieldNames, Is.Empty, "an unnamed tuple reports an empty list rather than null or a list of nulls"); Assert.That(childIsArray, Is.True, "a composite child re-enters the columnar surface"); Assert.That(childOffsets, Is.EqualTo(new[] { 0, 0, 1, 3 }), "the child array's own per-row offsets"); Assert.That(childInnerValues, Is.EqualTo(new[] { 0, 0, 1 })); diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs index b09227b75..54f7bb4b2 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs @@ -37,6 +37,15 @@ public async Task GetServerInfoAsync_AgainstRealServer_ReportsVersionMatchingSel // Negotiated, so it is the client's ceiling whenever the server offers at least that much. Assert.That(info.ProtocolRevision, Is.EqualTo(NegotiatedProtocol.ClientTcpProtocolVersion)); + + // The three revisions are separable: the negotiated one is the lower of the other two, and a server + // the CI matrix runs is newer than this client, so it advertises more than is in force. + Assert.That(info.ClientProtocolRevision, Is.EqualTo(NegotiatedProtocol.ClientTcpProtocolVersion)); + Assert.That(info.ServerProtocolRevision, Is.GreaterThanOrEqualTo(info.ProtocolRevision)); + Assert.That( + info.ProtocolRevision, + Is.EqualTo(Math.Min(info.ClientProtocolRevision, info.ServerProtocolRevision)), + "the revision in force is the lower of the two, which is what a feature gate reads."); }); } diff --git a/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs b/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs index 42307ebd2..15b59dc7d 100644 --- a/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs @@ -36,6 +36,43 @@ public void ToString_IsCultureInvariant() } } + [TestCase(null)] + [TestCase("")] + [TestCase("G")] + [TestCase("g")] + public void ToString_GeneralFormat_RendersFixedPointInvariant(string format) + { + var value = new ClickHouseTcpDecimal(new BigInteger(12345), 2); + Assert.That(value.ToString(format, CultureInfo.InvariantCulture), Is.EqualTo("123.45")); + } + + [TestCase("F3")] + [TestCase("N2")] + [TestCase("E")] + [TestCase("0.00")] + [TestCase("D")] + public void ToString_FormatItCannotRender_ThrowsFormatException(string format) + { + var value = new ClickHouseTcpDecimal(new BigInteger(12345), 2); + var ex = Assert.Throws(() => value.ToString(format, CultureInfo.InvariantCulture)); + Assert.That(ex.Message, Does.Contain(format).And.Contain(nameof(ClickHouseTcpDecimal.ToDecimal))); + } + + [Test] + public void ToString_InterpolatedWithAFormat_ThrowsRatherThanIgnoringIt() + { + // Interpolation reaches IFormattable, so an ignored format would silently render unrequested text. + var value = new ClickHouseTcpDecimal(new BigInteger(12345), 2); + Assert.Throws(() => _ = string.Format(CultureInfo.InvariantCulture, "{0:F3}", value)); + } + + [Test] + public void ToString_CultureWithAnotherSeparator_IsStillInvariant() + { + var value = new ClickHouseTcpDecimal(new BigInteger(12345), 2); + Assert.That(value.ToString(null, CultureInfo.GetCultureInfo("de-DE")), Is.EqualTo("123.45")); + } + [TestCase("123.45")] [TestCase("-123.45")] [TestCase("0")] diff --git a/ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs index b070d78b3..8d209992d 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs @@ -175,7 +175,11 @@ public async Task WriteColumn_ErgonomicColumnWithANullRow_ThrowsNamingTheRowAndT await CodecTestHarness.WriteAsync(writer => thrown = Assert.Throws(() => codec.WriteColumn(writer, column, 0, 2))); - Assert.That(thrown.Message, Does.Contain("null value at row 1").And.Contain("Array(Nullable(T))")); + Assert.Multiple(() => + { + Assert.That(thrown.Message, Does.Contain("null value at row 1").And.Contain("Array(Nullable(T))")); + Assert.That(thrown.ParamName, Is.EqualTo("column"), "the argument at fault, not an internal local's name."); + }); } // The state-aware overloads take the state this codec's own BeginWrite returned, and nothing else. They used to diff --git a/ClickHouse.Driver.Tcp.Tests/Types/TupleColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/TupleColumnCodecTests.cs index dfaf9dd7f..df607fc15 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/TupleColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/TupleColumnCodecTests.cs @@ -166,6 +166,24 @@ public async Task ReadColumn_NamedTuple_CarriesElementNamesAsMetadata() Assert.That(((TupleColumnBase)read).FieldNames, Is.EqualTo(new[] { "a", "b" })); } + [Test] + public async Task ReadColumn_UnnamedTuple_ReportsEmptyFieldNamesRatherThanNull() + { + // The interface promises an empty list over a null so a caller can enumerate it unguarded. The codec builds + // the unnamed shape by passing no names at all, so the normalization has to happen in the column. + IColumnCodec codec = Resolve("Tuple(Int32, String)"); + var column = new TupleColumn("c", "Tuple(Int32, String)", new (int, string)[] { (1, "a") }); + + using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, "Tuple(Int32, String)", 1); + + Assert.Multiple(() => + { + Assert.That(((ITupleColumn)read).FieldNames, Is.Not.Null); + Assert.That(((ITupleColumn)read).FieldNames, Is.Empty); + Assert.That(((ITupleColumn)column).FieldNames, Is.Empty, "a column built from rows names nothing either"); + }); + } + [Test] public async Task ReadColumn_NamedParametricElements_ResolveTypesAndRoundTrip() { diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpBlockWritten.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpBlockWritten.cs new file mode 100644 index 000000000..24d951666 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpBlockWritten.cs @@ -0,0 +1,58 @@ +namespace ClickHouse.Driver.Tcp; + +/// +/// One wire block an insert has finished sending. Reported through +/// . +/// +/// +/// +/// These are the client's own counters, not the server's. The server sends no Progress packet for the rows +/// a client streams to it during a native insert, so stays zero and +/// this is the only account an insert has of its own progress. A block reported here has been handed to the +/// socket; whether the server has applied it is a separate question, answered only by the insert completing. +/// +/// +/// The counters cover the block's body — its header, column names, types and values — and not the two-byte +/// packet envelope around it. +/// +/// +public readonly record struct ClickHouseTcpBlockWritten +{ + /// Initializes a new instance of the struct. + /// The block's zero-based position in the insert's send order. + /// The rows in this block. + /// The block body's size before compression. + /// The bytes this block put on the socket. + public ClickHouseTcpBlockWritten(int blockIndex, int rowCount, long uncompressedBytes, long compressedBytes) + { + BlockIndex = blockIndex; + RowCount = rowCount; + UncompressedBytes = uncompressedBytes; + CompressedBytes = compressedBytes; + } + + /// The block's zero-based position in the insert's send order. + public int BlockIndex { get; } + + /// + /// The rows in this block. Every block but the last holds the insert's block size, which is the lower of + /// and the total rows; this is where that setting + /// becomes observable. + /// + public int RowCount { get; } + + /// The block body's size before compression, which is what the rows cost to encode. + public long UncompressedBytes { get; } + + /// + /// The bytes this block put on the socket. Equal to when the client is not + /// compressing (a null ), and otherwise the framed and + /// compressed size. + /// + /// + /// Each frame carries a header and a checksum, so this can exceed on a + /// small block — a three-row block measured 110 bytes against 91 under LZ4. Read the two as a compression + /// ratio only over blocks large enough for the payload to dominate that overhead. + /// + public long CompressedBytes { get; } +} diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs index e46823192..bd4104429 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs @@ -69,6 +69,9 @@ public sealed class ClickHouseTcpClient : IClickHouseTcpClient, IDisposable // prefix declares. private const string JsonAsStringSetting = "output_format_native_write_json_as_string"; + // The setting ClickHouseTcpInsertOptions.DeduplicationToken is carried by. + private const string DeduplicationTokenSetting = "insert_deduplication_token"; + private static readonly ClickHouseTcpInsertOptions DefaultInsertOptions = new(); private readonly IConnectionSource source; @@ -157,7 +160,7 @@ public async IAsyncEnumerable StreamAsync( IReadOnlyDictionary settings = BuildSettings(options); IReadOnlyDictionary parameters = BuildParameters(sql, options); - string queryId = options?.QueryId; + string queryId = ResolveQueryId(options); // Started before the rent, so the span covers waiting for a connection, and so the Query packet's // trace-context field picks it up from Activity.Current. @@ -393,9 +396,10 @@ public async ValueTask ExecuteScalarAsync( } /// - /// Inserts columnar data. The columns are matched to the target's schema by name (order is free, and - /// a named subset inserts only those columns, the server filling the rest from their defaults); values are - /// serialized as the target's resolved type. Zero rows is a no-op. + /// Inserts columnar data. The columns are matched by name to the columns + /// lists — order is free, and one column is required for each of those names — + /// and values are serialized as the target's resolved type. The server fills any column the statement does + /// not list from its default. Zero rows is a no-op. /// /// The INSERT INTO … VALUES statement, with no inline VALUES (...) literal. /// The row data, matched to the target columns by name. @@ -413,10 +417,12 @@ public async ValueTask InsertAsync( ArgumentNullException.ThrowIfNull(sql); ArgumentNullException.ThrowIfNull(columns); - IReadOnlyDictionary settings = BuildSettings(options); + IReadOnlyDictionary settings = BuildInsertSettings(options); IReadOnlyDictionary parameters = BuildParameters(sql, options); - using ClientOperation operation = ClientOperation.Start(Options, logger, sql, options?.QueryId); + string queryId = ResolveQueryId(options); + + using ClientOperation operation = ClientOperation.Start(Options, logger, sql, queryId); try { await using IConnectionLease lease = await source.RentAsync(cancellationToken).ConfigureAwait(false); @@ -425,7 +431,7 @@ await lease.Connection.InsertAsync( columns, settings, parameters, - options?.QueryId, + queryId, ResolveMaxRowsPerBlock(options), Options.MaxSendBufferBytes, operation?.Telemetry, @@ -459,14 +465,16 @@ public async ValueTask InsertRowsAsync( nameof(rows)); } - IReadOnlyDictionary settings = BuildSettings(options); + IReadOnlyDictionary settings = BuildInsertSettings(options); IReadOnlyDictionary parameters = BuildParameters(sql, options); int? maxRowsPerBlock = ResolveMaxRowsPerBlock(options); int blockRows = ClickHouseTcpConnection.RowsPerBlock(rows.Count, maxRowsPerBlock); using var buffer = PocoRowBuffer.Create(rows, nameof(rows), blockRows, cancellationToken); - using ClientOperation operation = ClientOperation.Start(Options, logger, sql, options?.QueryId); + string queryId = ResolveQueryId(options); + + using ClientOperation operation = ClientOperation.Start(Options, logger, sql, queryId); try { await using IConnectionLease lease = await source.RentAsync(cancellationToken).ConfigureAwait(false); @@ -476,7 +484,7 @@ await lease.Connection.InsertAsync( schema => PocoTypes.WritePlanFor(schema).CreateSource(buffer, blockRows), settings, parameters, - options?.QueryId, + queryId, maxRowsPerBlock, Options.MaxSendBufferBytes, operation?.Telemetry, @@ -501,13 +509,15 @@ public async ValueTask InsertRowsAsync( ArgumentNullException.ThrowIfNull(sql); ArgumentNullException.ThrowIfNull(rows); - IReadOnlyDictionary settings = BuildSettings(options); + IReadOnlyDictionary settings = BuildInsertSettings(options); IReadOnlyDictionary parameters = BuildParameters(sql, options); int? maxRowsPerBlock = ResolveMaxRowsPerBlock(options); int blockRows = ClickHouseTcpConnection.RowsPerBlock(rows.Count, maxRowsPerBlock); using var buffer = PocoRowBuffer.Create(rows, nameof(rows), blockRows, cancellationToken); - using ClientOperation operation = ClientOperation.Start(Options, logger, sql, options?.QueryId); + string queryId = ResolveQueryId(options); + + using ClientOperation operation = ClientOperation.Start(Options, logger, sql, queryId); try { await using IConnectionLease lease = await source.RentAsync(cancellationToken).ConfigureAwait(false); @@ -517,7 +527,7 @@ await lease.Connection.InsertAsync( schema => UntypedRowColumns.CreateSource(schema, buffer, blockRows), settings, parameters, - options?.QueryId, + queryId, maxRowsPerBlock, Options.MaxSendBufferBytes, operation?.Telemetry, @@ -545,6 +555,21 @@ await lease.Connection.InsertAsync( internal static int? ResolveMaxRowsPerBlock(ClickHouseTcpInsertOptions options) => (options ?? DefaultInsertOptions).MaxRowsPerBlock; + /// The query id an operation runs under: the caller's, or a fresh one when they named none. + /// The per-operation options, or null for the client defaults. + /// A non-empty query id. + /// + /// The native protocol never sends back the id a server assigns, so an operation that leaves the field empty + /// cannot be found in system.query_log afterwards. Generating one client-side is what makes the id + /// knowable: every log line this operation writes carries it (see ), as does its + /// trace span. An empty string is treated as absent, which is how the server reads it. + /// + internal static string ResolveQueryId(ClickHouseTcpQueryOptions options) + { + string supplied = options?.QueryId; + return string.IsNullOrEmpty(supplied) ? Guid.NewGuid().ToString() : supplied; + } + /// /// Opens a session: one connection, taken from this client's pool and held until the session is disposed, that /// every operation on the returned object runs over. Server-side state a connection owns therefore survives from @@ -620,6 +645,8 @@ public async ValueTask GetServerInfoAsync(CancellationT VersionMinor = server.VersionMinor, VersionPatch = server.VersionPatch, ProtocolRevision = server.Negotiated.Version, + ServerProtocolRevision = server.Revision, + ClientProtocolRevision = NegotiatedProtocol.ClientTcpProtocolVersion, Timezone = server.Timezone, DisplayName = server.DisplayName, }; @@ -639,6 +666,30 @@ public async ValueTask GetServerInfoAsync(CancellationT private IReadOnlyDictionary BuildSettings(ClickHouseTcpQueryOptions options) => MergeSettings(Options.CustomSettings, options?.Settings); + /// + /// The settings for one insert: the query settings, plus + /// as its own server setting. + /// + /// The per-insert options, or null for the client defaults. + /// The merged settings to send with the insert. + /// The dedicated property wins over the same key in , + /// being the more specific way to say it. + private IReadOnlyDictionary BuildInsertSettings(ClickHouseTcpInsertOptions options) + { + IReadOnlyDictionary settings = BuildSettings(options); + if (string.IsNullOrEmpty(options?.DeduplicationToken)) + { + return settings; + } + + var withToken = new Dictionary(settings, StringComparer.Ordinal) + { + [DeduplicationTokenSetting] = options.DeduplicationToken, + }; + + return withToken; + } + /// /// Resolves each bound parameter to the wire text for the Query packet's parameter list. /// diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs index 92fc4dd0d..b8de2227f 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs @@ -58,8 +58,12 @@ public sealed record ClickHouseTcpClientOptions /// public int? Port { get; init; } - /// The port a connection actually dials: when set, otherwise derived from . - internal int ResolvedPort => Port ?? (UseTls ? DefaultTlsPort : DefaultPort); + /// + /// The port a connection actually dials: when set, otherwise derived from + /// . Read this rather than to report or check the endpoint, which is + /// null on a client that never named one. + /// + public int ResolvedPort => Port ?? (UseTls ? DefaultTlsPort : DefaultPort); /// The user to authenticate as. Defaults to default. public string Username { get; init; } = DefaultUsername; @@ -290,20 +294,22 @@ public sealed record ClickHouseTcpClientOptions public ClickHouseTcpPoolReusePolicy PoolReusePolicy { get; init; } = DefaultPoolReusePolicy; /// - /// Codec for the native protocol's compression frames, or to exchange blocks - /// uncompressed. Use (cheapest, lowest server-side load) or - /// (smaller, more CPU); a custom works if - /// it implements the native block path. + /// The codec the client's own blocks are framed with — what an insert writes — or + /// to exchange blocks uncompressed. Use (cheapest, lowest + /// server-side load) or (smaller, more CPU); a custom + /// works if it implements the native block path. /// - /// Compression is requested per query, so this is the default for every query the client runs. It governs - /// both directions: the server compresses the blocks it sends and expects the client's own blocks framed - /// the same way. Null means the request carries no compression at all, which is not the same as a frame - /// whose method byte is NONE. + /// It does not choose what the server sends. The request carries a flag saying whether to compress and + /// nothing that names a codec, so the server frames its own blocks with its network_compression_method + /// setting (LZ4 by default) and the client decodes whatever each frame's method byte declares. Asking for + /// ZSTD and being sent LZ4 is normal. To change what a result is compressed with, set that setting — + /// per query through , or client-wide through + /// . /// /// - /// A codec chooses the method byte and the body encoding, but never the decoding: the server picks its own - /// codec, so a client that asks for LZ4 can still be sent ZSTD and must decode whatever arrives. To steer - /// what the server sends, set its network_compression_method setting, here or per query. + /// Compression is requested per query, so this is the default for every query the client runs. Null means the + /// request asks for no compression at all in either direction, which is not the same as a frame whose method + /// byte is NONE. /// /// public IClickHouseCompressor Compressor { get; init; } = ResolveCompressor(DefaultCompression); diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs index 91f8eda19..d1f29ffc3 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs @@ -13,6 +13,10 @@ namespace ClickHouse.Driver.Tcp; /// /// /// +/// Depend on rather than on this class where the data source is +/// substituted or wrapped. +/// +/// /// A is already thread-safe and pooled, so this adds no pooling of its own. What /// it adds is a single owner: the client, and the pool behind it, belong to the data source. Everything else holds /// a client it must not dispose, because disposing it closes the pool for every other holder. Dispose the data @@ -24,7 +28,7 @@ namespace ClickHouse.Driver.Tcp; /// /// [Experimental("CHTCP0001")] -public sealed class ClickHouseTcpDataSource : IAsyncDisposable, IDisposable +public sealed class ClickHouseTcpDataSource : IClickHouseTcpDataSource { private readonly ClickHouseTcpClient client; diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpInsertOptions.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpInsertOptions.cs index e4b4dcc62..c7725d082 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpInsertOptions.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpInsertOptions.cs @@ -19,4 +19,33 @@ public sealed record ClickHouseTcpInsertOptions : ClickHouseTcpQueryOptions /// /// public int? MaxRowsPerBlock { get; init; } = ClickHouseTcpConnection.DefaultMaxRowsPerBlock; + + /// + /// A token identifying this batch of rows, so a repeat of the same insert under the same token is + /// discarded by the server instead of inserted twice. Null, the default, sends no token. Sets the server's + /// insert_deduplication_token setting. + /// + /// + /// + /// This is what makes an insert safe to retry. A + /// means the connection failed with the insert's outcome unknown, + /// so a plain retry can duplicate the rows. Retried under the same token, the server recognises the second + /// attempt and drops it, so the retry is safe whichever way the first attempt went. + /// + /// + /// Two rules make it work. The token must be derived from the data, not generated per attempt — one + /// token per batch, reused by every retry of that batch, and a new one for the next batch. And it must be + /// the same across retries of a failed attempt but different for a genuinely new batch, or a later + /// batch that happens to reuse a token is silently dropped. + /// + /// + /// The server keeps tokens per table and per partition, for a bounded window + /// (replicated_deduplication_window on a Replicated* engine, + /// non_replicated_deduplication_window otherwise, the latter off by default). A retry after the window + /// has passed is no longer recognised, so this bounds how late a retry can be, not how many times it can + /// happen. Setting the token in does the same thing; this + /// property wins when both name it. + /// + /// + public string DeduplicationToken { get; init; } } diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileInfo.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileInfo.cs index 180b7b190..0781682bc 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileInfo.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileInfo.cs @@ -13,7 +13,7 @@ public readonly record struct ClickHouseTcpProfileInfo /// Initializes a new instance of the struct. /// Rows in the result. /// Blocks in the result. - /// Bytes in the result. + /// The server's in-memory size of the result; see . /// Whether a LIMIT was applied. /// Rows before the LIMIT. /// Whether is meaningful. @@ -33,7 +33,14 @@ public ClickHouseTcpProfileInfo(ulong rows, ulong blocks, ulong bytes, bool appl /// Blocks in the result. public ulong Blocks { get; } - /// Bytes in the result. + /// + /// The server's own in-memory size for the result blocks — not the bytes it put on the wire, and not a + /// figure to size a transfer or a buffer from. It counts the server's column allocations, so a small result + /// reads far above its wire form: ten rows of (UInt64, UInt8) report 464 bytes against a 90-byte + /// uncompressed body, while at 200,000 rows the two nearly meet (1,801,216 against about 1,800,000) because + /// the values come to dominate the per-block overhead. Compression is applied after this is counted, so it + /// does not appear here at all. + /// public ulong Bytes { get; } /// Whether a LIMIT was applied. diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryCallbacks.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryCallbacks.cs index d192488b7..a8bde3d54 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryCallbacks.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryCallbacks.cs @@ -29,8 +29,30 @@ public sealed class ClickHouseTcpQueryCallbacks /// Called for each progress increment the server reports as the query runs. The counters are increments, not /// running totals — see . /// + /// + /// How often this fires is the server's interactive_delay setting, in microseconds, defaulting + /// to 100,000 — so about ten packets a second on a query that runs long enough. Lower it per query to drive a + /// smoother progress bar (Settings = { ["interactive_delay"] = "30000" }) and accept more packets for + /// the same result. A query that finishes inside one interval reports once or not at all, so a consumer must + /// not wait for a first packet before showing anything. + /// + /// An insert gets none of these: the server reports no progress for rows a client streams to it. Use + /// there. + /// + /// public Action OnProgress { get; init; } + /// + /// Called as each wire block of an insert finishes going out, with the client's own count of its rows and + /// bytes. Never called for a query, which sends no blocks. + /// + /// + /// This is an insert's progress, and the only one it has — see for why + /// cannot serve. It reports what the client has sent, not what the server has + /// applied, so a callback that has seen every block still does not know the insert succeeded. + /// + public Action OnBlockWritten { get; init; } + /// Called once with the query's execution summary (result rows, blocks, bytes, whether a LIMIT applied). public Action OnProfileInfo { get; init; } diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs index 45548198a..d8e2c999d 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs @@ -15,7 +15,24 @@ namespace ClickHouse.Driver.Tcp; /// public record ClickHouseTcpQueryOptions { - /// The query id, or null to let the server assign one. + /// + /// The query id, which identifies the operation in system.query_log and system.processes. Null + /// or empty lets the client generate one — a GUID, fresh per operation. + /// + /// + /// + /// The client generates one rather than leaving the field empty for the server to fill, because the native + /// protocol never sends the server's own id back: a query that left it empty could not afterwards be found in + /// the log. The id in force is written to every log line the operation produces (see + /// ) and to its trace span, whether the client generated + /// it or you supplied it. + /// + /// + /// Set it yourself to choose the id, which is what correlating with your own identifiers wants. Two + /// operations must not run at once under one id — the server rejects the second — so a value you supply + /// has to be unique per operation, not per caller. + /// + /// public string QueryId { get; init; } /// diff --git a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs index 78a1390b2..05de6a373 100644 --- a/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs +++ b/ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs @@ -7,6 +7,11 @@ namespace ClickHouse.Driver.Tcp; /// session defaults the client resolves timestamps against. Read it with /// . /// +/// +/// Three protocol revisions are reported, and they are usually three different numbers: what the server +/// advertised, what this client implements, and the negotiated one those two settle on. +/// is the one in force, and the one to gate a feature on. +/// public sealed record ClickHouseTcpServerInfo { /// The server identifier, normally "ClickHouse". @@ -22,11 +27,24 @@ public sealed record ClickHouseTcpServerInfo public int VersionPatch { get; init; } /// - /// The protocol revision in use for this connection: the lower of what the client and the server support, so - /// it can be below what either alone offers. Feature gates are decided against this number. + /// The protocol revision in use for this connection: the lower of and + /// , so it can be below what either alone offers. Gate features on + /// this number, not on either of the other two. /// public int ProtocolRevision { get; init; } + /// + /// The protocol revision the server advertised in its ServerHello — what it can do, not what is in force. + /// Higher than whenever the server is newer than this client. + /// + public int ServerProtocolRevision { get; init; } + + /// + /// The protocol revision this client implements — a constant of this driver version, the same for every + /// server. Higher than whenever the server is older than this client. + /// + public int ClientProtocolRevision { get; init; } + /// /// The server's timezone (e.g. "UTC"), which is what a bare DateTime column is interpreted in. /// Empty when the server did not send one. diff --git a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpDataSource.cs b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpDataSource.cs new file mode 100644 index 000000000..34b8b666e --- /dev/null +++ b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpDataSource.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace ClickHouse.Driver.Tcp; + +/// +/// What offers: the single owner of a client and its pool, for a container +/// to register and for a wrapper to forward. The counterpart of HTTP's . +/// +/// +/// +/// Depend on this rather than on the class where a test wants to substitute the data source, or where a +/// composition root wraps it — to add a per-tenant endpoint, a health gate, or metrics. Registering the +/// interface is what AddClickHouseTcpDataSource does. +/// +/// +/// Disposing this disposes the client too, which is the point: the data source is the one thing that +/// owns the pool. Nothing that receives 's result may dispose it. +/// +/// +/// This type is experimental: its surface may change in a future release. Suppress diagnostic +/// CHTCP0001 to acknowledge that. +/// +/// +[Experimental("CHTCP0001")] +public interface IClickHouseTcpDataSource : IAsyncDisposable, IDisposable +{ + /// The configuration every operation from this data source runs under. + ClickHouseTcpClientOptions Options { get; } + + /// + /// Returns the shared client: the same instance on every call, and the data source's to dispose, not the + /// caller's. + /// + /// The client this data source owns. + IClickHouseTcpClient GetClient(); + + /// + /// Opens a session on the shared pool: one connection, held until the session is disposed, that carries + /// server-side state such as a temporary table or a SET from one operation to the next. + /// + /// Unlike , a session is the caller's to dispose, and holds one of the + /// pool's connections until it is. + /// A token to observe while waiting for and establishing the connection. + /// A session pinned to one connection. + ValueTask OpenSessionAsync(CancellationToken cancellationToken = default); +} diff --git a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs index 047578270..9a40ac34b 100644 --- a/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs +++ b/ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs @@ -131,9 +131,10 @@ ValueTask ExecuteScalarAsync( CancellationToken cancellationToken = default); /// - /// Inserts columnar data. The columns are matched to the target's schema by name (order is free, and a - /// named subset inserts only those columns, the server filling the rest from their defaults); values are - /// serialized as the target's resolved type. Zero rows is a no-op. + /// Inserts columnar data. The columns are matched by name to the columns + /// lists — order is free, and one column is required for each of those names — and values are serialized as + /// the target's resolved type. The server fills any column the statement does not list from its default. + /// Zero rows is a no-op. /// /// The INSERT INTO … VALUES statement, with no inline VALUES (...) literal. /// The row data, matched to the target columns by name. diff --git a/ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs b/ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs index c10605f0e..564d69776 100644 --- a/ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs +++ b/ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs @@ -81,6 +81,7 @@ public async ValueTask CreateAsync(CancellationToken ca server.VersionMinor, server.VersionPatch, Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, + server.Negotiated.Version, server.Revision, server.Timezone); } diff --git a/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs b/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs index c32ad5d06..e651cb5cd 100644 --- a/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs +++ b/ClickHouse.Driver.Tcp/DependencyInjection/ClickHouseTcpServiceCollectionExtensions.cs @@ -14,9 +14,10 @@ namespace Microsoft.Extensions.DependencyInjection; /// /// /// Every overload registers one as a singleton and resolves -/// and from it, so the whole -/// application shares one connection pool. Singleton is the only lifetime offered: the data source owns a pool -/// that has to outlive every consumer, and the client is that pool rather than a per-consumer resource. +/// , and +/// from it, so the whole application shares one connection pool. +/// Singleton is the only lifetime offered: the data source owns a pool that has to outlive every consumer, and +/// the client is that pool rather than a per-consumer resource. /// /// /// Do not dispose the injected client. Disposing it closes the shared pool, so every other consumer's @@ -129,6 +130,10 @@ public static IServiceCollection AddClickHouseTcpDataSource( ArgumentNullException.ThrowIfNull(dataSourceFactory); services.TryAdd(new ServiceDescriptor(typeof(ClickHouseTcpDataSource), serviceKey, dataSourceFactory, ServiceLifetime.Singleton)); + + // Forwarded to the concrete singleton, so injecting either the class or the interface gets the one + // instance that owns the pool, and the provider disposes it once. + services.TryAdd(new ServiceDescriptor(typeof(IClickHouseTcpDataSource), serviceKey, static (sp, key) => GetService(sp, key), ServiceLifetime.Singleton)); services.TryAdd(new ServiceDescriptor(typeof(IClickHouseTcpClient), serviceKey, static (sp, key) => GetService(sp, key).GetClient(), ServiceLifetime.Singleton)); services.TryAdd(new ServiceDescriptor(typeof(IClickHouseTcpOperations), serviceKey, static (sp, key) => GetService(sp, key), ServiceLifetime.Singleton)); return services; diff --git a/ClickHouse.Driver.Tcp/Diagnostic/ClientOperation.cs b/ClickHouse.Driver.Tcp/Diagnostic/ClientOperation.cs index 81474243b..163edccb7 100644 --- a/ClickHouse.Driver.Tcp/Diagnostic/ClientOperation.cs +++ b/ClickHouse.Driver.Tcp/Diagnostic/ClientOperation.cs @@ -43,7 +43,7 @@ private ClientOperation(Activity activity, ILogger logger, string operationName, /// The client options the span's endpoint attributes come from. /// The client-category logger, or null when none is configured. /// The statement. - /// The caller's query id, or null to let the server assign one. + /// The query id in force, which every line this operation logs carries. /// The operation, or null when nothing is observing it. /// /// The caller's own callbacks are not this type's concern: the connection invokes them alongside diff --git a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseErrorCode.cs b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseErrorCode.cs index 32c8a2705..42f67e44b 100644 --- a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseErrorCode.cs +++ b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseErrorCode.cs @@ -31,6 +31,12 @@ public enum ClickHouseErrorCode /// The table has no column of that name. NoSuchColumnInTable = 16, + /// + /// A quoted string was expected and something else was found. What a query parameter named after a server + /// setting produces, because the server reads the name as that setting and then parses its value as one. + /// + CannotParseQuotedString = 26, + /// The input did not match the expected format. CannotParseInputAssertionFailed = 27, diff --git a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpServerException.cs b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpServerException.cs index 67f4d593e..83a6d31c4 100644 --- a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpServerException.cs +++ b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpServerException.cs @@ -23,11 +23,12 @@ public sealed class ClickHouseTcpServerException : ClickHouseTcpException /// Initializes a new instance of the class. /// The server-side error code. /// The server exception class name (e.g. "DB::Exception"). - /// The human-readable error message. + /// The error message. A leading : is stripped, since + /// reports it; pass the text exactly as the server sent it. /// The server-side stack trace. /// The nested server exception, or null for the innermost frame. public ClickHouseTcpServerException(int code, string name, string message, string serverStackTrace, Exception innerException = null) - : base(message, innerException) + : base(WithoutNamePrefix(name, message), innerException) { RawCode = code; Code = Enum.IsDefined((ClickHouseErrorCode)code) ? (ClickHouseErrorCode)code : ClickHouseErrorCode.Unknown; @@ -47,12 +48,35 @@ public ClickHouseTcpServerException(int code, string name, string message, strin /// public ClickHouseErrorCode Code { get; } - /// The server exception class name (e.g. "DB::Exception"). + /// + /// The server exception class name (e.g. "DB::Exception"). The server prefixes its message text with + /// this same name; reports the text without it, so the two do not repeat each + /// other. + /// public string Name { get; } /// The server-side stack trace. public string ServerStackTrace { get; } + /// + /// Strips a leading "{name}: " from the server's message text. The server writes its exception class + /// name into the message as well as into the name field, so keeping both would repeat it in every + /// ToString(), which already prints the type of this exception. + /// + /// The server exception class name. + /// The message text as the server sent it. + /// The message without the redundant prefix. + private static string WithoutNamePrefix(string name, string message) + { + if (string.IsNullOrEmpty(name) || message is null) + { + return message; + } + + string prefix = name + ": "; + return message.StartsWith(prefix, StringComparison.Ordinal) ? message[prefix.Length..] : message; + } + /// /// Decodes an Exception packet body (the bytes after the packet type code): Int32 code, /// String name, String message, String stack_trace, Bool has_nested. When diff --git a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpTransportException.cs b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpTransportException.cs index bb06c520c..622e99823 100644 --- a/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpTransportException.cs +++ b/ClickHouse.Driver.Tcp/Exceptions/ClickHouseTcpTransportException.cs @@ -17,6 +17,12 @@ namespace ClickHouse.Driver.Tcp; /// /// The connection is terminated and never reused. /// +/// +/// An insert that ends this way has an unknown outcome: the rows may have been applied before the +/// connection broke, or not at all, and nothing the client can read says which. So retrying one duplicates the +/// rows as often as it succeeds. Set to make the +/// retry safe — the server drops a second attempt carrying a token it has already seen. +/// /// public sealed class ClickHouseTcpTransportException : ClickHouseTcpException { diff --git a/ClickHouse.Driver.Tcp/Logging/ConnectionLog.cs b/ClickHouse.Driver.Tcp/Logging/ConnectionLog.cs index 192c8e2b8..323f22a0c 100644 --- a/ClickHouse.Driver.Tcp/Logging/ConnectionLog.cs +++ b/ClickHouse.Driver.Tcp/Logging/ConnectionLog.cs @@ -15,11 +15,13 @@ internal static partial class ConnectionLog Message = "Connecting to {Host}:{Port} as {Username} (TLS {Tls})")] public static partial void Opening(ILogger logger, string host, int port, string username, bool tls); + // Both revisions, because they differ whenever the client and the server are not the same age, and only the + // negotiated one governs which features the connection has. [LoggerMessage( EventId = 2001, Level = LogLevel.Debug, - Message = "Connected to {ServerName} {VersionMajor}.{VersionMinor}.{VersionPatch} in {ElapsedMs:0.###} ms: protocol revision {Revision}, server timezone {Timezone}")] - public static partial void Opened(ILogger logger, string serverName, int versionMajor, int versionMinor, int versionPatch, double elapsedMs, int revision, string timezone); + Message = "Connected to {ServerName} {VersionMajor}.{VersionMinor}.{VersionPatch} in {ElapsedMs:0.###} ms: protocol revision {NegotiatedRevision} in force (server advertised {ServerRevision}), server timezone {Timezone}")] + public static partial void Opened(ILogger logger, string serverName, int versionMajor, int versionMinor, int versionPatch, double elapsedMs, int negotiatedRevision, int serverRevision, string timezone); [LoggerMessage( EventId = 2002, diff --git a/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs b/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs index 156becab4..7dcee9466 100644 --- a/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs +++ b/ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs @@ -12,6 +12,11 @@ namespace ClickHouse.Driver.Tcp; /// /// Equality and comparison are value-based: 1.0 and 1.00 compare equal despite different scales. /// +/// +/// +/// There is one text form, invariant fixed-point, and no format string or culture selects another. See +/// . +/// /// public readonly struct ClickHouseTcpDecimal : IEquatable, IComparable, IFormattable { @@ -176,12 +181,34 @@ public override int GetHashCode() return HashCode.Combine(m, s); } - /// + /// Renders the value as invariant fixed-point: sign, integer part, then a . and exactly digits. + /// The rendered value. public override string ToString() => ToString(null, CultureInfo.InvariantCulture); - /// + /// + /// Renders the value as invariant fixed-point, the same text gives. Neither argument + /// changes the result: selects nothing, and is + /// ignored because the rendering is always invariant. + /// + /// + /// A format string other than the general one is rejected rather than ignored, so a call that asks for + /// a rendering this type cannot give fails loudly instead of returning differently formatted text than it + /// asked for. To format the value some other way, convert it with or + /// and format that. + /// + /// Null, empty, "G" or "g"; any other value throws. + /// Ignored. + /// The rendered value. + /// is not null, empty, "G" or "g". public string ToString(string format, IFormatProvider formatProvider) { + if (!string.IsNullOrEmpty(format) && format != "G" && format != "g") + { + throw new FormatException( + $"'{format}' is not a format {nameof(ClickHouseTcpDecimal)} can render; it has one text form, invariant fixed-point, selected by a null, empty, \"G\" or \"g\" format. " + + $"Convert the value with {nameof(ToDecimal)}() to format it another way."); + } + // A fixed-point rendering, always invariant: sign, integer part, then a '.' and exactly `scale` digits. BigInteger m = mantissa.ToBigInteger(); bool negative = m.Sign < 0; diff --git a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs index c1740757e..924d0e074 100644 --- a/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs +++ b/ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs @@ -20,6 +20,7 @@ internal sealed class ClickHouseBinaryWriter : IDisposable private readonly bool writesToTransport; private byte[] buffer; private int position; + private long flushed; // An int rather than a bool so disposal can be made exactly-once with Interlocked; see Dispose. private int disposed; @@ -51,6 +52,13 @@ public ClickHouseBinaryWriter(Stream stream, int bufferSize = 65536, bool writes /// The number of bytes buffered and not yet flushed. public int BufferedBytes => position; + /// + /// Every byte written to this writer since it was created, whether flushed yet or not. Counts what the writer + /// accepted rather than what reached the stream, so the difference across a stretch of writes is that + /// stretch's size no matter when a flush falls inside it. discards bytes it has counted. + /// + public long BytesWritten => flushed + position; + /// The number of bytes a VarUInt encoding of occupies (1–10). /// The value that would be written with . /// The encoded length in bytes. @@ -303,6 +311,7 @@ public async ValueTask FlushAsync(CancellationToken cancellationToken) if (position > 0) { await stream.WriteAsync(buffer.AsMemory(0, position), cancellationToken).ConfigureAwait(false); + flushed += position; position = 0; } diff --git a/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs b/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs index 4678b7f60..1d2b5ff18 100644 --- a/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs +++ b/ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs @@ -576,9 +576,9 @@ internal async IAsyncEnumerable QueryAsync( /// larger than the cap still buffers in full). Independent of the row-based block split. Defaults to /// . /// The client's own metadata observers, run before the caller's, or null. - /// The caller's callbacks for the metadata the server interleaves into the insert - /// acknowledgement (notably for rows written and - /// ), or null to discard it. + /// The caller's callbacks: per + /// block sent, plus the metadata the server interleaves into the acknowledgement + /// ( and the rest). Null discards all of it. /// A token to observe for cancellation. /// A task that completes when the server acknowledges the insert with end-of-stream. /// or is null. @@ -743,7 +743,8 @@ private async ValueTask InsertCoreAsync( // server to finish the insert. A gather failure is deferred the same way, so it still closes // cleanly and this stays a whole-packet boundary. flushedWholePackets = false; - Exception gatherFailure = await StreamInsertRowsAsync(plan, source, rowCount, maxRowsPerBlock, maxSendBufferBytes, negotiated, cancellationToken).ConfigureAwait(false); + Exception gatherFailure = await StreamInsertRowsAsync( + plan, source, rowCount, maxRowsPerBlock, maxSendBufferBytes, negotiated, telemetry, callbacks, cancellationToken).ConfigureAwait(false); flushedWholePackets = true; buildFailure ??= gatherFailure; @@ -865,6 +866,8 @@ private static void ValidateInsertGeometry(int? maxRowsPerBlock, int maxSendBuff /// The row source to fill the plan's columns from per block, or null when the caller /// supplied whole columns. /// The buffered-byte cap that triggers a between-column flush while a block is written. + /// The client's own metadata observers, run before the caller's, or null. + /// The caller's callbacks, for the per-block report, or null to report nothing. /// The gather failure that stopped the row stream, or null if every block was written. private async ValueTask StreamInsertRowsAsync( InsertColumn[] plan, @@ -873,11 +876,15 @@ private async ValueTask StreamInsertRowsAsync( int? maxRowsPerBlock, int flushThresholdBytes, NegotiatedProtocol negotiated, + ClickHouseTcpQueryCallbacks telemetry, + ClickHouseTcpQueryCallbacks callbacks, CancellationToken cancellationToken) { Exception gatherFailure = null; if (rowCount > 0 && plan is not null) { + int blockIndex = 0; + // Row count controls block splitting; the flush threshold bounds buffered output within each block. foreach ((int start, int length) in PlanInsertBlocks(rowCount, maxRowsPerBlock)) { @@ -896,9 +903,19 @@ private async ValueTask StreamInsertRowsAsync( } } - await WriteDataBlockPacketAsync( + (long uncompressed, long compressed) = await WriteDataBlockPacketAsync( negotiated, plan, source is null ? start : 0, length, flushThresholdBytes, cancellationToken).ConfigureAwait(false); await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + + // Reported after the flush, so a block the caller hears about is one the socket has taken. + if (telemetry?.OnBlockWritten is not null || callbacks?.OnBlockWritten is not null) + { + var written = new ClickHouseTcpBlockWritten(blockIndex, length, uncompressed, compressed); + telemetry?.OnBlockWritten?.Invoke(written); + callbacks?.OnBlockWritten?.Invoke(written); + } + + blockIndex++; } } @@ -1027,7 +1044,7 @@ private static InsertColumn[] BuildInsertPlan(IReadOnlyList columns, Bl if (validateWritable && !codec.CanWrite(slot.Values)) { - error = $"Column '{slot.Name}' was given a value column of type {slot.Values.GetType()}, whose CLR element type the target type '{slot.TypeName}' does not accept."; + error = DescribeUnwritableColumn(slot, codec); return null; } @@ -1037,6 +1054,43 @@ private static InsertColumn[] BuildInsertPlan(IReadOnlyList columns, Bl return plan; } + /// Composes the message for a column whose CLR element type the target type cannot be written from. + /// The plan slot: the target's name and type, and the column the caller supplied. + /// The target type's codec, for the element types it accepts. + /// The message. + private static string DescribeUnwritableColumn(InsertColumn slot, IColumnCodec codec) + { + // The caller wrote the element type, not the column class holding it, so that is what the message names. + // A column implementing IColumn<> zero or several times has no single element type; fall back to the class + // rather than let the diagnostic throw over the diagnosis. + string present; + try + { + present = slot.Values.ElementType.ToString(); + } + catch (InvalidOperationException) + { + present = slot.Values.GetType().ToString(); + } + + IReadOnlyList writable = codec.WritableElementTypes; + var offered = new List(writable.Count); + for (int i = 0; i < writable.Count; i++) + { + if (codec.CanWriteElementType(writable[i])) + { + offered.Add(writable[i].ToString()); + } + } + + // Empty means the type is written only from a column shape this codec builds itself. + string remedy = offered.Count == 0 + ? $"No column built from a CLR element type can fill a '{slot.TypeName}' column; re-insert one read back from a query of the same type." + : $"It accepts {string.Join(" or ", offered)}, and — for a composite type — a column whose elements are any type its element codecs accept."; + + return $"Column '{slot.Name}' ({slot.TypeName}) was given a column of element type {present}, which it cannot be written from. " + remedy; + } + /// Composes a message naming the columns the caller failed to supply and the ones it supplied in excess. private static string DescribeSchemaMismatch(IReadOnlyList columns, Block schema, List missing) { @@ -1133,7 +1187,9 @@ private async ValueTask WriteEndOfInputBlockAsync(CancellationToken cancellation /// The number of rows the block holds. /// The buffered-byte cap that triggers a between-column flush. /// A token to observe for cancellation. - private async ValueTask WriteDataBlockPacketAsync( + /// The block body's size before compression, and the bytes it put on the socket. Both measure the + /// body only, so they agree exactly when nothing is compressing. + private async ValueTask<(long Uncompressed, long Compressed)> WriteDataBlockPacketAsync( NegotiatedProtocol negotiated, IReadOnlyList columns, int start, @@ -1144,15 +1200,25 @@ private async ValueTask WriteDataBlockPacketAsync( writer.WriteClientPacketType(ClientPacketType.Data); writer.WriteString(string.Empty); // table_name: empty for the INSERT row stream, and never framed + // Counted after the envelope and before the body, so the totals are the body's own size whichever path + // writes it. BytesWritten counts what a writer accepted, so a flush inside the body does not lose any. if (compressor is null) { + long before = writer.BytesWritten; await BlockWriter.WriteDataBlockBodyAsync(writer, negotiated, columns, start, rowCount, flushThresholdBytes, cancellationToken).ConfigureAwait(false); - return; + long body = writer.BytesWritten - before; + return (body, body); } frameWriter ??= new CompressedFrameWriter(writer, compressor); + long plaintextBefore = frameWriter.Writer.BytesWritten; + long framedBefore = writer.BytesWritten; await BlockWriter.WriteDataBlockBodyAsync(frameWriter.Writer, negotiated, columns, start, rowCount, flushThresholdBytes, cancellationToken).ConfigureAwait(false); + + // Read after EndBlockAsync, which flushes the last frame: until it runs, the tail of the body is still + // plaintext in the frame writer and has cost no framed bytes yet. await frameWriter.EndBlockAsync(cancellationToken).ConfigureAwait(false); + return (frameWriter.Writer.BytesWritten - plaintextBefore, writer.BytesWritten - framedBefore); } /// diff --git a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt index 43f05f586..42ed8dd06 100644 --- a/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt +++ b/ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt @@ -19,6 +19,7 @@ ClickHouse.Driver.Tcp.ClickHouseErrorCode.AuthenticationFailed = 516 -> ClickHou ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotConvertType = 70 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotInsertNullInOrdinaryColumn = 349 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotParseInputAssertionFailed = 27 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotParseQuotedString = 26 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotParseText = 6 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.CannotReadAllData = 33 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.ClientHasConnectedToWrongPort = 217 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode @@ -66,6 +67,14 @@ ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnknownUser = 192 -> ClickHouse.Driver ClickHouse.Driver.Tcp.ClickHouseErrorCode.UnsupportedMethod = 1 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.ValueIsOutOfRangeOfDataType = 321 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode ClickHouse.Driver.Tcp.ClickHouseErrorCode.WrongPassword = 193 -> ClickHouse.Driver.Tcp.ClickHouseErrorCode +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.BlockIndex.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.ClickHouseTcpBlockWritten() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.ClickHouseTcpBlockWritten(int blockIndex, int rowCount, long uncompressedBytes, long compressedBytes) -> void +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.CompressedBytes.get -> long +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten other) -> bool +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.RowCount.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.UncompressedBytes.get -> long ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ClickHouseTcpClientOptions() -> void @@ -110,6 +119,7 @@ ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ReadTimeout.get -> System.TimeS ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ReadTimeout.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.StatementMaxLength.get -> int ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.StatementMaxLength.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ResolvedPort.get -> int ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.SweepInterval.get -> System.TimeSpan? ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.SweepInterval.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.TlsAllowInvalidCertificates.get -> bool @@ -190,6 +200,8 @@ ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics ClickHouse.Driver.Tcp.ClickHouseTcpException ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.ClickHouseTcpInsertOptions() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.DeduplicationToken.get -> string +ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.DeduplicationToken.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions other) -> bool ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.MaxRowsPerBlock.get -> int? ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.MaxRowsPerBlock.init -> void @@ -245,6 +257,8 @@ ClickHouse.Driver.Tcp.ClickHouseTcpProtocolException.ClickHouseTcpProtocolExcept ClickHouse.Driver.Tcp.ClickHouseTcpProtocolException.ClickHouseTcpProtocolException(string message, System.Exception innerException) -> void ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.ClickHouseTcpQueryCallbacks() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnBlockWritten.get -> System.Action +ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnBlockWritten.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnExtremes.get -> System.Action ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnExtremes.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpQueryCallbacks.OnLog.get -> System.Action @@ -277,6 +291,8 @@ ClickHouse.Driver.Tcp.ClickHouseTcpServerException.ServerStackTrace.get -> strin ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.$() -> ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ClickHouseTcpServerInfo() -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ClientProtocolRevision.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ClientProtocolRevision.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.DisplayName.get -> string ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.DisplayName.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo other) -> bool @@ -284,6 +300,8 @@ ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Name.get -> string ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Name.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ProtocolRevision.get -> int ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ProtocolRevision.init -> void +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ServerProtocolRevision.get -> int +ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ServerProtocolRevision.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Timezone.get -> string ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Timezone.init -> void ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Version.get -> System.Version @@ -378,6 +396,9 @@ const ClickHouse.Driver.Tcp.ClickHouseTcpDiagnostics.PoolLogCategory = "ClickHou const ClickHouse.Driver.Tcp.Int256.Size = 32 -> int const ClickHouse.Driver.Tcp.IVariantColumn.NullDiscriminator = 255 -> byte const ClickHouse.Driver.Tcp.UInt256.Size = 32 -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.Equals(object obj) -> bool +override ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.GetHashCode() -> int +override ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.ToString() -> string override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.Equals(object obj) -> bool override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.GetHashCode() -> int override ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.ToString() -> string @@ -403,11 +424,9 @@ override ClickHouse.Driver.Tcp.ClickHouseTcpProgress.ToString() -> string override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.Equals(object obj) -> bool override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.GetHashCode() -> int override ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.ToString() -> string -override ClickHouse.Driver.Tcp.ClickHouseTcpServerException.IsTransient.get -> bool override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.Equals(object obj) -> bool override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.GetHashCode() -> int override ClickHouse.Driver.Tcp.ClickHouseTcpServerInfo.ToString() -> string -override ClickHouse.Driver.Tcp.ClickHouseTcpTransportException.IsTransient.get -> bool override ClickHouse.Driver.Tcp.Int256.Equals(object obj) -> bool override ClickHouse.Driver.Tcp.Int256.GetHashCode() -> int override ClickHouse.Driver.Tcp.Int256.ToString() -> string @@ -415,6 +434,8 @@ override ClickHouse.Driver.Tcp.UInt256.Equals(object obj) -> bool override ClickHouse.Driver.Tcp.UInt256.GetHashCode() -> int override ClickHouse.Driver.Tcp.UInt256.ToString() -> string override sealed ClickHouse.Driver.Tcp.ClickHouseTcpInsertOptions.Equals(ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions other) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten left, ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten right) -> bool +static ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten left, ClickHouse.Driver.Tcp.ClickHouseTcpBlockWritten right) -> bool static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.FromConnectionString(string connectionString) -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.operator !=(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions right) -> bool static ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions.operator ==(ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions left, ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions right) -> bool @@ -492,6 +513,10 @@ virtual ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions.PrintMembers(System.Text [CHTCP0001]ClickHouse.Driver.Tcp.ClickHouseTcpDataSource.Options.get -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpClient [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpClient.OpenSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpDataSource +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpDataSource.GetClient() -> ClickHouse.Driver.Tcp.IClickHouseTcpClient +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpDataSource.OpenSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpDataSource.Options.get -> ClickHouse.Driver.Tcp.ClickHouseTcpClientOptions [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.ExecuteAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [CHTCP0001]ClickHouse.Driver.Tcp.IClickHouseTcpOperations.ExecuteScalarAsync(string sql, ClickHouse.Driver.Tcp.ClickHouseTcpQueryOptions options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs b/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs index fa11d471c..fafb28f7b 100644 --- a/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs @@ -13,11 +13,19 @@ namespace ClickHouse.Driver.Tcp; /// /// /// Give each column the target column's name: an insert matches by name, not position, so the order is -/// free and naming a subset of the table inserts only those columns, the server filling the rest from their -/// defaults. You do not state the ClickHouse type — the server sends the target's schema before any row data and +/// free. You do not state the ClickHouse type — the server sends the target's schema before any row data and /// that is what the values are serialized as, so a column built here reports a null -/// . If is not a CLR type the target column accepts, -/// the insert fails with an naming both. +/// . If is not a CLR type the target column accepts, the +/// insert fails with an naming the target's ClickHouse type, the CLR element +/// type it was given, and the element types it does accept. +/// +/// +/// The statement's column list decides which columns are inserted, not the columns you build. You must +/// supply one column for every name the statement lists, and no others: INSERT INTO t (id, name) VALUES +/// takes exactly id and name, and the server fills t's remaining columns from their +/// defaults. Listing three names and passing two is an naming what is missing — +/// so a subset is something you write into the statement, not something you get by leaving a column out. A +/// statement with no list at all targets every column of the table. /// /// /// Pick to match the target: Int32 takes an int, String a diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/IArrayWriteShape.cs b/ClickHouse.Driver.Tcp/Types/Codecs/IArrayWriteShape.cs index 085827ce1..0c35e2be3 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/IArrayWriteShape.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/IArrayWriteShape.cs @@ -51,7 +51,7 @@ public int[] ComputeOffsets(IColumn column, int start, int length) { throw new ArgumentException( $"Array column '{source.Name}' has a null value at row {start + i}; Array(T) rows are non-nullable. Use an empty array for an empty row, or declare the column Array(Nullable(T)) to carry null elements.", - "source"); + nameof(column)); } total64 += (ulong)row.Length; diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs index b29239f65..1b60e2615 100644 --- a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -11,10 +11,12 @@ namespace ClickHouse.Driver.Tcp; /// /// /// The default view undoes the transposition and hands back a per-row -/// [] ([] for QBit(Float64, N), [] for -/// QBit(Int8, N)), which is convenient but -/// reverses the layout the type exists to provide. This interface exposes the planes as stored, so a caller -/// computing a reduced-precision distance can read the few planes it needs without materializing any vector. +/// [] ([] for QBit(Float64, N), and [] for +/// QBit(Int8, N), which needs a server of 26.7 or newer — an older one refuses to declare the type at +/// all, reporting that QBit supports only BFloat16, Float32 and Float64). That view +/// is convenient but reverses the layout the type exists to provide. This interface exposes the planes as +/// stored, so a caller computing a reduced-precision distance can read the few planes it needs without +/// materializing any vector. /// /// /// @@ -39,10 +41,16 @@ public interface IQBitColumn : IColumn /// /// /// ClickHouse 26.7 added an optional stride that splits a row into independent - /// groups of stride elements, each carrying its own full set of planes. A - /// column of that shape currently fails to resolve, so always reports - /// — it exists so a caller reading planes is written against the general layout - /// rather than against the single-group special case. + /// groups of stride elements, each carrying its own full set of planes. This + /// client does not decode that shape and refuses such a column with a + /// , so always reports . It + /// exists so a caller reading planes is written against the general layout rather than against the + /// single-group special case. + /// + /// + /// Reaching that refusal needs a 26.7 or newer server: an older one rejects a three-argument + /// QBit when the table is created, saying the type family takes exactly two arguments, so on those + /// servers no strided column can exist to be read. /// /// int Stride { get; } diff --git a/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs b/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs index ee3a02bce..4a396a9c8 100644 --- a/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/ITupleColumn.cs @@ -24,10 +24,12 @@ public interface ITupleColumn : IColumn IReadOnlyList Children { get; } /// - /// The element names for a named tuple (Tuple(a Int32, b String)) — one entry per element, aligned - /// with , with a null entry for an unnamed element; null when the tuple carries no - /// names at all. Names are metadata only: they do not affect the wire layout or the materialized - /// ValueTuple value. + /// The element names for a named tuple (Tuple(a Int32, b String)) — one entry per element, aligned with + /// . Empty, never null, for an unnamed tuple (Tuple(Int32, String)), so it + /// can be enumerated without a null check; test + /// to tell the two apart. A tuple is named or unnamed as a whole — the server rejects a type that names some + /// elements and not others — so every entry of a non-empty list is a name. Names are metadata only: they do + /// not affect the wire layout or the materialized ValueTuple value. /// IReadOnlyList FieldNames { get; } } diff --git a/ClickHouse.Driver.Tcp/Types/TupleColumn.cs b/ClickHouse.Driver.Tcp/Types/TupleColumn.cs index 090eb299f..4793d78a1 100644 --- a/ClickHouse.Driver.Tcp/Types/TupleColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/TupleColumn.cs @@ -22,7 +22,8 @@ internal abstract class TupleColumnBase : ITupleColumn /// The column name. /// The full Tuple(...) type string (element names included when named). /// The child columns, one per element; each must be an of the corresponding element type. - /// The element names (one per element, null entry for an unnamed element), or null when the tuple is unnamed. + /// The element names, one per element, or null when the tuple is unnamed; a null is + /// surfaced as an empty , which the interface promises over a null. /// Whether disposing this column disposes the child columns. /// is null. /// is empty, or the children disagree on their row count. @@ -31,7 +32,7 @@ protected TupleColumnBase(string name, string typeName, IColumn[] children, IRea this.children = children ?? throw new ArgumentNullException(nameof(children)); Name = name; TypeName = typeName; - FieldNames = fieldNames; + FieldNames = fieldNames ?? Array.Empty(); this.ownsChildren = ownsChildren; // A tuple stores one value per element per row, so every child is exactly as tall as the tuple and no