Skip to content
Open
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
217 changes: 217 additions & 0 deletions ClickHouse.Driver.Tcp.Tests/Types/FixedStringColumnCodecTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ClickHouse.Driver.Tcp.Protocol;
using ClickHouse.Driver.Tcp.Types;

namespace ClickHouse.Driver.Tcp.Tests.Types;

[TestFixture]
public class FixedStringColumnCodecTests
{
private static readonly CancellationToken None = CancellationToken.None;

[Test]
public void Create_MissingOrNonIntegerOrNonPositiveLength_ThrowsFormat()
{
Assert.Multiple(() =>
{
Assert.Throws<FormatException>(() => Resolve("FixedString"));
Assert.Throws<FormatException>(() => Resolve("FixedString(x)"));
Assert.Throws<FormatException>(() => Resolve("FixedString(0)"));
Assert.Throws<FormatException>(() => Resolve("FixedString(-4)"));
Assert.Throws<FormatException>(() => Resolve("FixedString(4, 5)"));
});
}

[Test]
public async Task WriteColumn_ExactWidthValue_WritesBytesVerbatim()
{
byte[] value = { 0xDE, 0xAD, 0xBE, 0xEF };
byte[] bytes = await WriteAsync(w => Codec(4).WriteColumn(w, new ArrayColumn<byte[]>("c", "FixedString(4)", new[] { value })));

CollectionAssert.AreEqual(value, bytes);
}

// A value of any width other than N is rejected rather than padded or truncated: padding a short value would
// silently rewrite the caller's data and hide whatever produced the wrong width. This matches the HTTP path's
// FixedStringType, which requires a byte[] to be exactly N bytes.
[TestCase(0, TestName = "WriteColumn_EmptyValue_ThrowsArgument")]
[TestCase(3, TestName = "WriteColumn_ValueShorterThanWidth_ThrowsArgument")]
[TestCase(7, TestName = "WriteColumn_ValueLongerThanWidth_ThrowsArgument")]
public async Task WriteColumn_ValueWidthOtherThanN_ThrowsArgument(int valueLength)
{
var column = new ArrayColumn<byte[]>("c", "FixedString(6)", new[] { new byte[valueLength] });
var ex = await CaptureAsync(w => Codec(6).WriteColumn(w, column));

Assert.That(ex, Is.TypeOf<ArgumentException>());
Assert.That(ex.Message, Does.Contain("exactly 6 bytes"));
}

[Test]
public async Task WriteColumn_NullRow_ThrowsArgument()
{
var column = new ArrayColumn<byte[]>("c", "FixedString(4)", new byte[][] { null });
var ex = await CaptureAsync(w => Codec(4).WriteColumn(w, column));

Assert.That(ex, Is.TypeOf<ArgumentException>());
}

[Test]
public async Task RoundTrip_MultipleRowsWithEmbeddedNulAndNonUtf8_PreservedAtFixedStride()
{
var values = new[]
{
new byte[] { 0, 0, 0, 0 },
new byte[] { (byte)'A', 0x00, (byte)'B', 0xFF },
new byte[] { 0xFF, 0xFE, 0xFD, 0xFC },
};

byte[] bytes = await WriteAsync(w => Codec(4).WriteColumn(w, new ArrayColumn<byte[]>("c", "FixedString(4)", values)));
using var reader = ReaderOver(bytes);
using var column = (FixedStringColumn)await Codec(4).ReadColumnAsync(reader, "c", "FixedString(4)", values.Length, None);

Assert.Multiple(() =>
{
CollectionAssert.AreEqual(values[1], column.GetBytes(1).ToArray());
Assert.That(column.GetString(1, Encoding.Latin1), Is.EqualTo("A\0Bÿ"));
CollectionAssert.AreEqual(values, column.Values.ToArray());
});
}

[Test]
public async Task ReadColumn_ZeroRows_ReturnsEmptyColumn()
{
using var reader = ReaderOver(Array.Empty<byte>());
using var column = (IColumn<byte[]>)await Codec(4).ReadColumnAsync(reader, "c", "FixedString(4)", 0, None);

Assert.That(column.RowCount, Is.EqualTo(0));
}

[Test]
public async Task ReadColumn_IndexOrGetBytesBeyondRowCount_Throws()
{
// The read path rents the blob from the pool, so it is typically larger than rowCount * N. Access beyond
// RowCount must still fail fast rather than return a stale pooled slot — both before and after the cache
// is materialized by touching Values.
var values = new[] { new byte[] { 1, 2 }, new byte[] { 3, 4 } };
byte[] bytes = await WriteAsync(w => Codec(2).WriteColumn(w, new ArrayColumn<byte[]>("c", "FixedString(2)", values)));
using var reader = ReaderOver(bytes);
using var column = (FixedStringColumn)await Codec(2).ReadColumnAsync(reader, "c", "FixedString(2)", values.Length, None);

Assert.Multiple(() =>
{
Assert.Throws<IndexOutOfRangeException>(() => _ = column.GetBytes(values.Length).Length);
Assert.Throws<IndexOutOfRangeException>(() => _ = column[values.Length]);
_ = column.Values.Length; // materialize the cache, then re-check the indexer
Assert.Throws<IndexOutOfRangeException>(() => _ = column[values.Length]);
});
}

[Test]
public async Task WriteColumn_DenseColumnSubRange_BlitsOnlyThatRangeOfTheBlob()
{
// The dense read-back holds its rows at the wire stride, so the codec blits the range in one copy instead
// of walking it. The insert path splits a large column into per-block ranges, so a partial range must emit
// exactly its own rows — a stride slip would show up as neighbouring rows' bytes.
using var dense = await DenseAsync(2, new byte[] { 1, 1 }, new byte[] { 2, 2 }, new byte[] { 3, 3 });
byte[] bytes = await WriteAsync(w => Codec(2).WriteColumn(w, dense, start: 1, length: 2));

CollectionAssert.AreEqual(new byte[] { 2, 2, 3, 3 }, bytes);
}

[Test]
public async Task WriteColumn_DenseColumnOfDifferentWidth_ThrowsArgument()
{
// A FixedString(2) read-back is not a valid body for a FixedString(4) column: blitting its blob would emit
// half the bytes the header promises and corrupt the block. The width guard must send it down the per-row
// path, which rejects each row on width — a shape no server round-trip can produce, hence the unit test.
using var dense = await DenseAsync(2, new byte[] { 1, 2 }, new byte[] { 3, 4 });
var ex = await CaptureAsync(w => Codec(4).WriteColumn(w, dense));

Assert.That(ex, Is.TypeOf<ArgumentException>());
Assert.That(ex.Message, Does.Contain("exactly 4 bytes"));
}

[Test]
public async Task GetBytes_RangeBeyondRowCount_ThrowsArgumentOutOfRange()
{
// The blob is rented and typically longer than rowCount * N, so an over-long range must fail fast rather
// than blit a stale pooled region into the block.
using var dense = await DenseAsync(2, new byte[] { 1, 2 }, new byte[] { 3, 4 });

Assert.Multiple(() =>
{
Assert.Throws<ArgumentOutOfRangeException>(() => _ = dense.GetBytes(0, 3).Length);
Assert.Throws<ArgumentOutOfRangeException>(() => _ = dense.GetBytes(1, 2).Length);
Assert.Throws<ArgumentOutOfRangeException>(() => _ = dense.GetBytes(-1, 1).Length);
Assert.Throws<ArgumentOutOfRangeException>(() => _ = dense.GetBytes(0, -1).Length);
CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4 }, dense.GetBytes(0, 2).ToArray());
Assert.That(dense.GetBytes(2, 0).Length, Is.EqualTo(0));
});
}

[Test]
public void NullPlaceholder_IsWidthZeroBytes()
{
// Nullable substitutes this at a null position, and the values stream must still advance a full row there,
// so the placeholder has to be exactly N bytes now that a short value is rejected.
CollectionAssert.AreEqual(new byte[6], (byte[])Codec(6).NullPlaceholder);
}

[Test]
public void CanWrite_AcceptsByteArrayColumn_RejectsOthers()
{
Assert.Multiple(() =>
{
Assert.That(Codec(4).CanWrite(new ArrayColumn<byte[]>("c", "FixedString(4)", new[] { new byte[4] })), Is.True);
Assert.That(Codec(4).CanWrite(new ArrayColumn<string>("c", "String", new[] { "x" })), Is.False);
});
}

private static IColumnCodec Codec(int size) => ColumnCodecRegistry.Default.Resolve($"FixedString({size})", ResolveContext.ForWrite);

// Builds the dense, blob-backed column the read path produces — the shape the codec's bulk-blit write covers —
// by writing the values and reading them straight back.
private static async Task<FixedStringColumn> DenseAsync(int size, params byte[][] values)
{
string type = $"FixedString({size})";
byte[] bytes = await WriteAsync(w => Codec(size).WriteColumn(w, new ArrayColumn<byte[]>("c", type, values)));
using var reader = ReaderOver(bytes);
return (FixedStringColumn)await Codec(size).ReadColumnAsync(reader, "c", type, values.Length, None);
}

private static void Resolve(string type) => ColumnCodecRegistry.Default.Resolve(type, ResolveContext.ForWrite);

private static async Task<byte[]> WriteAsync(Action<ClickHouseBinaryWriter> write)
{
using var ms = new MemoryStream();
using (var writer = new ClickHouseBinaryWriter(ms))
{
write(writer);
await writer.FlushAsync(None);
}

return ms.ToArray();
}

private static async Task<Exception> CaptureAsync(Action<ClickHouseBinaryWriter> write)
{
using var ms = new MemoryStream();
using var writer = new ClickHouseBinaryWriter(ms);
try
{
write(writer);
await writer.FlushAsync(None);
return null;
}
catch (Exception ex)
{
return ex;
}
}

private static ClickHouseBinaryReader ReaderOver(byte[] bytes) => new(new MemoryStream(bytes));
}
40 changes: 40 additions & 0 deletions ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ public static IEnumerable<InsertRoundTripCase> Cases()

yield return Strings("String", string.Empty, "hello", "héllo✓", "a\0b", new string('x', 500));

// FixedString(N): N contiguous bytes per row, surfaced as a per-row byte[] of exactly N bytes. The bytes
// are byte-oriented, so embedded NULs and non-UTF-8 bytes ride along unchanged. A wider N crosses the
// stride past a single row so a mis-strided blit could not pass unnoticed.
yield return FixedStrings(4, new byte[] { 0, 0, 0, 0 }, new byte[] { 1, 2, 3, 4 }, new byte[] { 0xFF, 0x00, 0xFF, 0x00 });
yield return FixedStrings(200, Enumerable.Range(0, 200).Select(i => (byte)i).ToArray(), new byte[200]);

yield return Dates("Date", new DateOnly(1970, 1, 1), new DateOnly(2024, 1, 15), new DateOnly(2149, 6, 6));
yield return Dates("Date32", new DateOnly(1900, 1, 1), new DateOnly(1970, 1, 1), new DateOnly(2024, 1, 15), new DateOnly(2299, 12, 31));

Expand Down Expand Up @@ -252,6 +258,12 @@ public static IEnumerable<InsertRoundTripCase> Cases()
yield return NullableStrings("hello", null, "world", string.Empty);
yield return NullableStrings(null, null); // every row null

// Nullable(FixedString(N)): byte[] is reference-typed, so a null row surfaces as null; present rows are
// exactly N bytes. A null row must not reach the FixedString codec (the nullable write substitutes the
// N-zero-byte placeholder instead), so the all-null case proves the placeholder-only values stream.
yield return NullableFixedStrings(4, new byte[] { 1, 2, 3, 4 }, null, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF });
yield return NullableFixedStrings(4, null, null); // every row null

// IPv4/IPv6 are reference-typed (IPAddress) but fixed-width; a null row must not reach the IP codec (it
// dereferences the address), so the nullable write substitutes a placeholder instead.
yield return NullableIps("IPv4", "127.0.0.1", null, "255.255.255.255");
Expand Down Expand Up @@ -281,6 +293,7 @@ public static IEnumerable<InsertRoundTripCase> Cases()
yield return Arrays("Float64", new[] { 0d, -1.5e100, double.MaxValue });
yield return Arrays("Bool", new[] { true, false, true }, Array.Empty<bool>());
yield return Arrays("String", new[] { "a", "bb" }, Array.Empty<string>(), new[] { string.Empty, "héllo✓" });
yield return Arrays<byte[]>("FixedString(4)", new[] { new byte[] { 1, 2, 3, 4 }, new byte[] { 0xFF, 0, 0xFF, 0 } }, Array.Empty<byte[]>());
yield return Arrays("Date", new[] { new DateOnly(1970, 1, 1), new DateOnly(2149, 6, 6) }, Array.Empty<DateOnly>());
yield return Arrays("Date32", new[] { new DateOnly(1900, 1, 1), new DateOnly(2299, 12, 31) });

Expand Down Expand Up @@ -346,6 +359,18 @@ public static IEnumerable<InsertRoundTripCase> Cases()
"Tuple(Int32)",
name => new TupleColumn<int>(name, "Tuple(Int32)", new[] { new ValueTuple<int>(1), new ValueTuple<int>(int.MinValue), new ValueTuple<int>(int.MaxValue) }));

// FixedString(N) as a tuple element: the write path reaches the FixedString codec through a
// TupleFieldColumn projection rather than a dense blob, so it takes the strict per-value branch instead of
// the bulk blit — the one entrance the bare, Nullable and Array cases all miss.
yield return Same(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Map(FixedString()) test cases worth adding

"Tuple(FixedString(4), String)",
"Tuple(FixedString(4), String)",
name => new TupleColumn<byte[], string>(name, "Tuple(FixedString(4), String)", new (byte[], string)[]
{
(new byte[] { 1, 2, 3, 4 }, "a"),
(new byte[] { 0xFF, 0x00, 0xFF, 0x00 }, string.Empty),
}));

// Arity 3 was the one arity between 1 and 7 with no case at all.
yield return Same(
"Tuple(Int32, String, Float64) [arity 3]",
Expand Down Expand Up @@ -673,6 +698,21 @@ private static InsertRoundTripCase Primitive<T>(string clickHouseType, T[] value
private static InsertRoundTripCase Strings(string clickHouseType, params string[] values)
=> Same($"{clickHouseType} [{values.Length} rows]", clickHouseType, name => new ArrayColumn<string>(name, clickHouseType, values));

// FixedString(N) inserts and reads back a per-row byte[]. Every value must be exactly N bytes: the write path
// rejects any other width rather than padding or truncating, so a wrong-width case belongs in the codec's unit
// tests (it never reaches the server), not here.
private static InsertRoundTripCase FixedStrings(int size, params byte[][] values)
{
string type = $"FixedString({size})";
return Same($"{type} [{values.Length} rows]", type, name => new ArrayColumn<byte[]>(name, type, values));
}

private static InsertRoundTripCase NullableFixedStrings(int size, params byte[][] values)
{
string type = $"Nullable(FixedString({size}))";
return Same($"{type} [{values.Length} rows]", type, name => new ArrayColumn<byte[]>(name, type, values));
}

// BFloat16 widens to float; values are chosen to be exactly representable so the narrow-on-write is lossless.
private static InsertRoundTripCase BFloat16s(string clickHouseType, IReadOnlyDictionary<string, string> settings, params float[] values)
=> Same($"{clickHouseType} [{values.Length} rows]", clickHouseType, name => new ArrayColumn<float>(name, clickHouseType, values), settings);
Expand Down
Loading
Loading