Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions ClickHouse.Driver.Tcp.Tests/Integration/GeometryIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ClickHouse.Driver.Tcp.Format;
using ClickHouse.Driver.Tcp.Protocol;
using ClickHouse.Driver.Tcp.Tests.Utilities;
using ClickHouse.Driver.Tcp.Types;
using ClickHouse.Driver.Tcp.Types.Codecs;

namespace ClickHouse.Driver.Tcp.Tests.Integration;

/// <summary>
/// Pins the <c>Geometry</c> discriminator order against the server. The column header carries only the alias, so
/// the client expands it to a six-alternative <c>Variant</c> from a constant of its own; nothing on the wire
/// carries that ordering, and a caller of the dense column depends on it to mean what they intend.
///
/// <para>
/// A round trip cannot check that constant, because the client applies it to the write and to the read. If two
/// alternatives are transposed, the write picks the wrong discriminator and the read of that discriminator picks
/// the same wrong alternative back, so the value returns intact while the server holds it under the other type.
/// Transposing <c>Point</c> or <c>MultiPolygon</c> is still caught: the wrong alternative has a different layout,
/// so the bytes stop parsing. The other four are two structurally identical pairs (<c>Ring</c> with
/// <c>LineString</c>, <c>Polygon</c> with <c>MultiLineString</c>) whose blocks are byte-identical, so nothing
/// objects. Their CLR types are identical too, so knowing a value's type does not name its alternative either.
/// </para>
///
/// <para>
/// Only the server breaks that symmetry, and it has to be asked what it calls a given row. Asking what it calls
/// discriminator <c>i</c> answers from its own list and never consults the client's. So one row is written against
/// each discriminator, and the server's name for that row is compared with the client's name for it.
/// </para>
/// </summary>
[TestFixture]
[Category("Integration")]
[RequiresServerFeature(TcpFeature.Geometry)]
public class GeometryIntegrationTests
{
private static readonly CancellationToken None = CancellationToken.None;
private static readonly (double, double)[] Square = { (0d, 0d), (2d, 0d), (2d, 2d), (0d, 0d) };

[Test]
public async Task InsertAsync_OneRowPerDiscriminator_TheServerNamesEachOneWhatTheClientCallsIt()
{
var codec = (VariantColumnCodec)ColumnCodecRegistry.Default.Resolve("Geometry", ResolveContext.ForWrite);
IReadOnlyList<string> clientOrder = codec.AlternativeTypeNames;

await using var connection = await TcpServerFixture.ConnectAsync(None);
string table = UniqueTableName();
try
{
await ExecuteAsync(connection, $"CREATE TABLE {table} (id UInt8, value Geometry) ENGINE = Memory");

// Row i selects discriminator i and carries a value of the shape the client's alternative i expects, so
// the server's name for row i is directly comparable with clientOrder[i].
var alternatives = new IColumn[clientOrder.Count];
var discriminators = new byte[clientOrder.Count];
var ids = new byte[clientOrder.Count];
for (int i = 0; i < clientOrder.Count; i++)
{
alternatives[i] = SampleFor(clientOrder[i]);
discriminators[i] = (byte)i;
ids[i] = (byte)i;
}

using var value = new VariantColumn("value", "Geometry", discriminators, alternatives, clientOrder.Count, pooledDiscriminators: false, ownsColumns: false);
var id = PrimitiveColumn<byte>.FromValues("id", "UInt8", ids);
await connection.InsertAsync($"INSERT INTO {table} (id, value) VALUES", new IColumn[] { id, value }, cancellationToken: None);

// toString, because variantType returns an Enum8 and this client surfaces an enum as its raw ordinal —
// which is the very numbering under test, so reading it back would prove nothing.
var served = new List<string>();
await foreach (Block block in connection.QueryAsync($"SELECT toString(variantType(value)) FROM {table} ORDER BY id", cancellationToken: None))
{
for (int row = 0; row < block.RowCount; row++)
{
served.Add((string)block[0].GetValue(row));
}
}

Assert.That(served, Is.EqualTo(clientOrder), "the client's alternative order must be the server's");
}
finally
{
await ExecuteAsync(connection, $"DROP TABLE IF EXISTS {table}");
}
}

// A one-row column of the shape the named geo alias surfaces as.
private static IColumn SampleFor(string alias) => alias switch
{
"Point" => new ArrayColumn<(double, double)>("value", alias, new[] { (1.5d, -2.5d) }),
"Ring" or "LineString" => new ArrayColumn<(double, double)[]>("value", alias, new[] { Square }),
"Polygon" or "MultiLineString" => new ArrayColumn<(double, double)[][]>("value", alias, new[] { new[] { Square } }),
"MultiPolygon" => new ArrayColumn<(double, double)[][][]>("value", alias, new[] { new[] { new[] { Square } } }),
_ => throw new ArgumentException($"Geometry gained an alternative this test does not know: '{alias}'.", nameof(alias)),
};

private static string UniqueTableName() => $"tcp_geometry_test_{Guid.NewGuid():N}";

private static async Task ExecuteAsync(ClickHouseTcpConnection connection, string sql)
{
await foreach (Block block in connection.QueryAsync(sql, cancellationToken: None))
{
_ = block;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ public async Task InsertRowsAsync_EveryCorpusType_WritesThePropertyIntoTheColumn
IColumn insert = testCase.BuildInsertColumn("value");
string sql = $"INSERT INTO {table} (value) VALUES";

// Four of Geometry's six alternatives are two structurally identical pairs, so a gathered row of either
// shape surfaces a CLR type that names no single alternative and no value-level test can separate them.
if (testCase.ClickHouseType == "Geometry")
{
ArgumentException ambiguous = Assert.ThrowsAsync<ArgumentException>(
async () => await InsertColumnAsRowsAsync(client, sql, insertOptions, insert, ElementTypeOf(insert)));

Assert.That(ambiguous.Message, Does.Contain("does not say which of them is meant"));
return;
}

// Nested and composites containing it require a specialized column shape that row gathering cannot build.
if (testCase.ClickHouseType.Contains("Nested(", StringComparison.Ordinal))
{
Expand Down
77 changes: 76 additions & 1 deletion ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,86 @@ public void Resolve_DateTimeWithTimezone_StampsFullTypeName()
Assert.That(codec.TypeName, Is.EqualTo("DateTime('UTC')"));
}

// A parseable type the registry has no factory for. Deliberately not a real ClickHouse type: every real one this
// stood for has since been implemented, and the fallback itself is what the test is about.
[Test]
public void Resolve_UnsupportedButWellFormedType_ThrowsNotSupported()
=> Assert.Throws<NotSupportedException>(() => ColumnCodecRegistry.Default.Resolve("Point", default));
=> Assert.Throws<NotSupportedException>(() => ColumnCodecRegistry.Default.Resolve("NotAType(UInt8)", default));

[Test]
public void Resolve_MalformedType_ThrowsFormat()
=> Assert.Throws<FormatException>(() => ColumnCodecRegistry.Default.Resolve(string.Empty, default));

// The geo aliases name structures the client already encodes. The round-trip corpus proves the values; what
// only a resolution test reaches is that the alias survives as the codec's own name — a codec reporting
// "Tuple(Float64, Float64)" would still round-trip, and would still misname the type in every diagnostic.
[TestCase("Point", typeof((double, double)))]
[TestCase("Ring", typeof((double, double)[]))]
[TestCase("LineString", typeof((double, double)[]))]
[TestCase("Polygon", typeof((double, double)[][]))]
[TestCase("MultiLineString", typeof((double, double)[][]))]
[TestCase("MultiPolygon", typeof((double, double)[][][]))]
public void Resolve_GeoAlias_KeepsTheAliasNameAndSurfacesTheStructuralType(string type, Type elementType)
{
IColumnCodec codec = ColumnCodecRegistry.Default.Resolve(type, default);
Assert.Multiple(() =>
{
Assert.That(codec.TypeName, Is.EqualTo(type));
Assert.That(codec.ElementType, Is.EqualTo(elementType));
});
}

// Geometry expands to a Variant the header never spells out. The client applies its own alternative order to
// the write and to the read, so no round trip can see a transposition of Ring with LineString or Polygon with
// MultiLineString: the same wrong order on both sides returns the value intact, and each pair is
// byte-identical so the server never objects. GeometryIntegrationTests pins the order by asking the server.
[Test]
public void Resolve_Geometry_KeepsTheAliasNameAndSurfacesTheVariantType()
{
IColumnCodec codec = ColumnCodecRegistry.Default.Resolve("Geometry", default);
Assert.Multiple(() =>
{
Assert.That(codec.TypeName, Is.EqualTo("Geometry"));
Assert.That(codec.ElementType, Is.EqualTo(typeof(object)));
});
}


// SimpleAggregateFunction encodes as its inner type, so it must resolve to the inner codec *itself* rather than
// to a renaming wrapper — which the corpus cannot tell apart, since a wrapper would round-trip identically.
// The aliased inner additionally pins that the inner goes back through the registry: a resolver that only
// handled plain type names would fail there and nowhere else. The composite inners pin that the whole parsed
// node reaches the registry with its own arguments, not just its name — including when the function itself is
// parameterized, which parses as a node with arguments of its own and must not be read as a second inner type.
[TestCase("SimpleAggregateFunction(sum, UInt64)", "UInt64")]
[TestCase("SimpleAggregateFunction(anyLast, Point)", "Point")]
[TestCase("SimpleAggregateFunction(groupArrayArray, Array(UInt64))", "Array(UInt64)")]
[TestCase("SimpleAggregateFunction(maxMap, Map(String, UInt64))", "Map(String, UInt64)")]
[TestCase("SimpleAggregateFunction(groupArrayLastArray(10), Array(String))", "Array(String)")]
public void Resolve_SimpleAggregateFunction_ResolvesToTheInnerTypesCodec(string type, string innerTypeName)
{
IColumnCodec codec = ColumnCodecRegistry.Default.Resolve(type, default);
Assert.That(codec.TypeName, Is.EqualTo(innerTypeName));
}

[TestCase("SimpleAggregateFunction(sum)")]
[TestCase("SimpleAggregateFunction(sum, UInt64, UInt8)")]
public void Resolve_SimpleAggregateFunctionWithoutExactlyOneInnerType_ThrowsFormat(string type)
=> Assert.Throws<FormatException>(() => ColumnCodecRegistry.Default.Resolve(type, default));

// AggregateFunction holds the function's own intermediate state, which no generic codec decodes. The hint has to
// be a query that actually runs: the combinator attaches to the bare name, and a parameterized function keeps its
// parameters in their own list ahead of the column. "quantiles(0.5, 0.9)Merge" is not a function, and a bare
// "quantilesMerge(column)" is rejected by the server for wanting its parameters — verified on 26.6.
[TestCase("AggregateFunction(sum, UInt64)", "sumMerge(column)")]
[TestCase("AggregateFunction(quantiles(0.5, 0.9), UInt64)", "quantilesMerge(0.5, 0.9)(column)")]
public void Resolve_AggregateFunction_ThrowsSuggestingAMergeQueryThatRuns(string type, string expectedHint)
{
var exception = Assert.Throws<NotSupportedException>(() => ColumnCodecRegistry.Default.Resolve(type, default));
Assert.That(exception.Message, Does.Contain(expectedHint));
}

[Test]
public void Resolve_AggregateFunctionNamingNoFunction_ThrowsFormat()
=> Assert.Throws<FormatException>(() => ColumnCodecRegistry.Default.Resolve("AggregateFunction()", default));
}
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ public void Resolve_WrongArgumentCount_ThrowsFormat(string type)

[Test]
public void Resolve_UnsupportedInner_ThrowsNotSupported()
=> Assert.Throws<NotSupportedException>(() => Resolve("Nullable(Point)"));
=> Assert.Throws<NotSupportedException>(() => Resolve("Nullable(NotAType)"));

[Test]
public void Resolve_Nullable_StampsFullTypeName()
Expand Down
79 changes: 79 additions & 0 deletions ClickHouse.Driver.Tcp.Tests/Types/VariantColumnCodecTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using ClickHouse.Driver.Tcp.Protocol;
using ClickHouse.Driver.Tcp.Tests.Utilities;
Expand Down Expand Up @@ -124,6 +126,83 @@ public void WriteColumn_ValueWithNoMatchingAlternative_Throws()
Assert.ThrowsAsync<ArgumentException>(async () => await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)));
}

// Several alternatives can share a CLR element type even though the server forbids duplicate alternative types
// — they only have to surface the same one. JSON and String are both string; Int64, DateTime64 and Time64 are
// all long; Geometry collides twice over (Ring/LineString, Polygon/MultiLineString). No alternative claims
// such a value, and picking one would store it as the wrong type with no error. The message names the
// alternatives it could not choose between, and prescribes nothing: there is no way for a caller to say which
// one is meant.
[TestCaseSource(nameof(AmbiguousAlternativeCases))]
public void WriteColumn_ValueWhoseClrTypeSeveralAlternativesShare_ThrowsNamingThem(string type, object ambiguous, string[] expectedNames)
{
IColumnCodec codec = Resolve(type);
var column = new ArrayColumn<object>("v", type, new[] { ambiguous });

ArgumentException refusal = Assert.ThrowsAsync<ArgumentException>(
async () => await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)));
Assert.Multiple(() =>
{
foreach (string name in expectedNames)
{
Assert.That(refusal.Message, Does.Contain($"'{name}'"));
}

Assert.That(refusal.Message, Does.Not.Contain("VariantColumn"));
});
}

// A collision the value itself settles: IPv4 and IPv6 both surface IPAddress, but an address carries its
// family, so exactly one alternative claims it and the write goes through. This is the only one of the four
// collision families a value-level test can resolve — a string says nothing about JSON versus String, a long
// nothing about Int64 versus DateTime64, and a Ring nothing about LineString.
[TestCase("127.0.0.1", 0, TestName = "An IPv4 address goes to the IPv4 alternative")]
[TestCase("::1", 1, TestName = "An IPv6 address goes to the IPv6 alternative")]
public async Task WriteColumn_IpValueWhoseFamilyNamesOneAlternative_WritesThatDiscriminator(string address, byte expectedDiscriminator)
{
const string Type = "Variant(IPv4, IPv6, String)";
IColumnCodec codec = Resolve(Type);
var column = new ArrayColumn<object>("v", Type, new object[] { IPAddress.Parse(address) });

byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column));
Assert.That(bytes[0], Is.EqualTo(expectedDiscriminator));
}

// The refusal above is per value, not per column: an IColumn<object> says nothing about the runtime types it
// holds, so refusing the whole column would also reject every unambiguous value in it. A string is unambiguous
// in Variant(IPv4, IPv6, String) and a UInt64 is in Variant(JSON, String, UInt64), and both still write.
[TestCaseSource(nameof(UnambiguousAlternativeCases))]
public async Task WriteColumn_ValueWhoseClrTypeOneAlternativeHas_WritesEvenWhenOthersCollide(string type, object unambiguous)
{
IColumnCodec codec = Resolve(type);
var column = new ArrayColumn<object>("v", type, new[] { unambiguous });

byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column));
Assert.That(bytes, Is.Not.Empty);
}

private static IEnumerable<TestCaseData> AmbiguousAlternativeCases()
{
yield return new TestCaseData("Variant(JSON, String, UInt64)", (object)"{}", new[] { "JSON", "String" })
.SetName("JSON and String are both string");

// Three, not two. Striking a colliding type from the map must not free the key for the next alternative to
// claim: an odd-sized collision group would then resolve to its last member and write every such value as
// that alternative. All three of these surface the raw long the wire carries.
yield return new TestCaseData("Variant(DateTime64(3), Int64, Time64(3))", (object)5L, new[] { "DateTime64(3)", "Int64", "Time64(3)" })
.SetName("Int64, DateTime64 and Time64 are all long");

// The structural pairs inside Geometry, which no value-level test can separate.
yield return new TestCaseData("Geometry", (object)new[] { (0d, 0d), (1d, 1d) }, new[] { "LineString", "Ring" })
.SetName("LineString and Ring are both Array(Point)");
}

private static IEnumerable<TestCaseData> UnambiguousAlternativeCases()
{
yield return new TestCaseData("Variant(IPv4, IPv6, String)", (object)"abc").SetName("String beside the colliding IP pair");
yield return new TestCaseData("Variant(JSON, String, UInt64)", (object)7UL).SetName("UInt64 beside the colliding text pair");
yield return new TestCaseData("Variant(DateTime64(3), Int64, String, Time64(3))", (object)"abc").SetName("String beside the colliding long trio");
}

[Test]
public async Task WriteColumn_DenseColumnSlice_WritesOnlyTheSlicedRowsAndTheirValues()
{
Expand Down
Loading
Loading