This is an automated email from the ASF dual-hosted git repository.
CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-dotnet.git
The following commit(s) were added to refs/heads/main by this push:
new 2d5b4cb feat: Support SQL-NULL rows when shredding variant columns
(#445)
2d5b4cb is described below
commit 2d5b4cbf6652b3305276e02efc4f1fcb0338fcf9
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Sat Sep 26 21:01:39 2026 -0700
feat: Support SQL-NULL rows when shredding variant columns (#445)
## What's Changed
The shredding pipeline had no way to represent a SQL-NULL row: every
entry point took `VariantValue`, and `ShreddedVariantArrayBuilder.Build`
always produced a storage struct with no validity. Callers had to use
`VariantValue.Null` as a placeholder, which is stored as a present
variant null and changes what `IS NULL` means.
This follows the `VariantValue?` convention already used by
`VariantArray.Builder`:
- `ShredSchemaInferer.Infer(IEnumerable<VariantValue?>, ShredOptions)`
ignores null rows, so they don't count toward frequency thresholds. An
all-null column infers as unshredded.
- `VariantShredder.Shred(IEnumerable<VariantValue?>, ShredSchema)`
leaves null rows out of the shared metadata and returns a `null` entry
for each one.
- `ShreddedVariantArrayBuilder.Build` treats a `null` entry in `rows` as
a null element of the storage struct. Its children are emitted as
missing, and `metadata` is still populated because that field is
required. No validity buffer is allocated when there are no nulls.
- `VariantUnshredder.Reconstruct` returns `null` for a `null` result
instead of throwing, so rows from the nullable `Shred` overload can be
unshredded one at a time.
Existing signatures are unchanged. Passing an untyped `null` or
`default` literal for `values` (e.g. `Shred(null, schema)`) is now
ambiguous between the overloads. Such a call can only throw
`ArgumentNullException`, so this is unlikely to matter in practice.
Typed null variables, arrays and collection expressions all still
resolve as before, and `VariantArray.Builder.AppendRange` already uses
the same overload pair.
This lays the groundwork for the array-level entry points proposed in
#399, which can read validity directly from the input `VariantArray`.
Closes #398.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../Shredding/ShredSchemaInferer.cs | 22 +++
.../Shredding/ShreddedVariantArrayBuilder.cs | 35 ++++-
.../Shredding/VariantShredder.cs | 33 +++++
.../Shredding/VariantUnshredder.cs | 12 +-
.../Shredding/ShredNullRowTests.cs | 164 +++++++++++++++++++++
5 files changed, 259 insertions(+), 7 deletions(-)
diff --git a/src/Apache.Arrow.Operations/Shredding/ShredSchemaInferer.cs
b/src/Apache.Arrow.Operations/Shredding/ShredSchemaInferer.cs
index 4537c3c..ed29532 100644
--- a/src/Apache.Arrow.Operations/Shredding/ShredSchemaInferer.cs
+++ b/src/Apache.Arrow.Operations/Shredding/ShredSchemaInferer.cs
@@ -53,6 +53,28 @@ namespace Apache.Arrow.Operations.Shredding
return BuildSchema(stats, totalCount, options, 0);
}
+ /// <summary>
+ /// Infers a shredding schema by analyzing the given nullable values.
+ /// <c>null</c> entries are SQL-NULL rows and are ignored; they carry
no type
+ /// information and do not count toward frequency thresholds.
+ /// </summary>
+ /// <param name="values">The variant values to analyze.</param>
+ /// <param name="options">Options controlling depth, frequency, and
type consistency thresholds.</param>
+ /// <returns>An inferred <see cref="ShredSchema"/>.</returns>
+ public ShredSchema Infer(IEnumerable<VariantValue?> values,
ShredOptions options = null)
+ {
+ if (values == null) throw new
ArgumentNullException(nameof(values));
+ return Infer(NonNull(values), options);
+ }
+
+ private static IEnumerable<VariantValue>
NonNull(IEnumerable<VariantValue?> values)
+ {
+ foreach (VariantValue? value in values)
+ {
+ if (value.HasValue) yield return value.Value;
+ }
+ }
+
private void CollectStats(VariantValue value, TypeStats stats, int
depth, int maxDepth)
{
ShredType type = VariantShredder.GetShredType(value);
diff --git
a/src/Apache.Arrow.Operations/Shredding/ShreddedVariantArrayBuilder.cs
b/src/Apache.Arrow.Operations/Shredding/ShreddedVariantArrayBuilder.cs
index 44b6de5..3428fa1 100644
--- a/src/Apache.Arrow.Operations/Shredding/ShreddedVariantArrayBuilder.cs
+++ b/src/Apache.Arrow.Operations/Shredding/ShreddedVariantArrayBuilder.cs
@@ -36,7 +36,10 @@ namespace Apache.Arrow.Operations.Shredding
/// </summary>
/// <param name="schema">The shredding schema applied to each
row.</param>
/// <param name="metadata">The column-level variant metadata (shared
across rows).</param>
- /// <param name="rows">Per-row shred results whose residual bytes
reference <paramref name="metadata"/>.</param>
+ /// <param name="rows">
+ /// Per-row shred results whose residual bytes reference <paramref
name="metadata"/>.
+ /// A <c>null</c> entry produces a null (SQL-NULL) element in the
resulting array.
+ /// </param>
/// <param name="allocator">Arrow memory allocator, or default if
null.</param>
public static VariantArray Build(
ShredSchema schema,
@@ -50,6 +53,33 @@ namespace Apache.Arrow.Operations.Shredding
int rowCount = rows.Count;
+ // Top-level validity. Children of a null row are emitted as
missing
+ // (value and typed_value both null); metadata is still populated
+ // because the metadata field is non-nullable.
+ ArrowBuffer.BitmapBuilder validity = new
ArrowBuffer.BitmapBuilder(rowCount);
+ int nullCount = 0;
+ ShredResult[] childRows = null;
+ for (int i = 0; i < rowCount; i++)
+ {
+ if (rows[i] == null)
+ {
+ if (childRows == null)
+ {
+ childRows = new ShredResult[rowCount];
+ for (int j = 0; j < i; j++) childRows[j] = rows[j];
+ }
+ childRows[i] = ShredResult.Missing;
+ validity.Append(false);
+ nullCount++;
+ }
+ else
+ {
+ if (childRows != null) childRows[i] = rows[i];
+ validity.Append(true);
+ }
+ }
+ if (childRows != null) rows = childRows;
+
// metadata column: emit the shared bytes once per row. (A
dictionary-encoded
// or run-end-encoded representation would compress this;
VariantArray's reader
// already handles those, but for simplicity we emit the plain
binary form.)
@@ -81,8 +111,9 @@ namespace Apache.Arrow.Operations.Shredding
}
StructType structType = new StructType(fields);
+ ArrowBuffer nullBitmap = nullCount > 0 ? validity.Build(allocator)
: ArrowBuffer.Empty;
StructArray structArr = new StructArray(
- structType, rowCount, children, ArrowBuffer.Empty, nullCount:
0);
+ structType, rowCount, children, nullBitmap, nullCount);
// The public VariantArray(IArrowArray) constructor infers the
VariantType
// from the struct's shape (including detecting the shredded
layout).
return new VariantArray(structArr);
diff --git a/src/Apache.Arrow.Operations/Shredding/VariantShredder.cs
b/src/Apache.Arrow.Operations/Shredding/VariantShredder.cs
index ef2ed11..501e028 100644
--- a/src/Apache.Arrow.Operations/Shredding/VariantShredder.cs
+++ b/src/Apache.Arrow.Operations/Shredding/VariantShredder.cs
@@ -66,6 +66,39 @@ namespace Apache.Arrow.Operations.Shredding
return (metadataBytes, results);
}
+ /// <summary>
+ /// Shreds a column of nullable variant values. A <c>null</c> entry is
a
+ /// SQL-NULL row, as opposed to <see cref="VariantValue.Null"/>, which
is a
+ /// present variant null. SQL-NULL rows produce a <c>null</c> entry in
the
+ /// returned rows, which <see
cref="ShreddedVariantArrayBuilder.Build"/> turns
+ /// into a null element of the resulting array.
+ /// </summary>
+ public static (byte[] Metadata, IReadOnlyList<ShredResult> Rows) Shred(
+ IEnumerable<VariantValue?> values,
+ ShredSchema schema)
+ {
+ if (values == null) throw new
ArgumentNullException(nameof(values));
+ if (schema == null) throw new
ArgumentNullException(nameof(schema));
+
+ List<VariantValue?> rows = values as List<VariantValue?> ?? new
List<VariantValue?>(values);
+
+ VariantMetadataBuilder metadata = new VariantMetadataBuilder();
+ foreach (VariantValue? row in rows)
+ {
+ if (row.HasValue) CollectFieldNames(row.Value, metadata);
+ }
+ byte[] metadataBytes = metadata.Build(out int[] idRemap);
+
+ ShredResult[] results = new ShredResult[rows.Count];
+ for (int i = 0; i < rows.Count; i++)
+ {
+ VariantValue? row = rows[i];
+ results[i] = row.HasValue ? Shred(row.Value, schema, metadata,
idRemap) : null;
+ }
+
+ return (metadataBytes, results);
+ }
+
/// <summary>
/// Shreds a single variant value against a caller-managed metadata
dictionary.
/// Use this when combining shredded columns with external metadata,
or when
diff --git a/src/Apache.Arrow.Operations/Shredding/VariantUnshredder.cs
b/src/Apache.Arrow.Operations/Shredding/VariantUnshredder.cs
index 346e57d..ecc9e10 100644
--- a/src/Apache.Arrow.Operations/Shredding/VariantUnshredder.cs
+++ b/src/Apache.Arrow.Operations/Shredding/VariantUnshredder.cs
@@ -30,19 +30,21 @@ namespace Apache.Arrow.Operations.Shredding
/// <summary>
/// Reconstructs a variant value from a shredded result.
/// </summary>
- /// <param name="shredded">The shredded (value, typed_value)
pair.</param>
+ /// <param name="shredded">
+ /// The shredded (value, typed_value) pair, or null for a SQL-NULL row
as produced by
+ /// <see
cref="VariantShredder.Shred(System.Collections.Generic.IEnumerable{VariantValue?},
ShredSchema)"/>.
+ /// </param>
/// <param name="schema">The shredding schema that was used to produce
the result.</param>
/// <param name="metadata">The column-level variant metadata
bytes.</param>
/// <returns>
- /// The reconstructed <see cref="VariantValue"/>, or null if the field
is missing
- /// (both value and typed_value are null).
+ /// The reconstructed <see cref="VariantValue"/>, or null if <paramref
name="shredded"/>
+ /// is null (a SQL-NULL row) or the field is missing (both value and
typed_value are null).
/// </returns>
public static VariantValue? Reconstruct(ShredResult shredded,
ShredSchema schema, ReadOnlySpan<byte> metadata)
{
- if (shredded == null) throw new
ArgumentNullException(nameof(shredded));
if (schema == null) throw new
ArgumentNullException(nameof(schema));
- if (shredded.IsMissing)
+ if (shredded == null || shredded.IsMissing)
{
return null;
}
diff --git a/test/Apache.Arrow.Operations.Tests/Shredding/ShredNullRowTests.cs
b/test/Apache.Arrow.Operations.Tests/Shredding/ShredNullRowTests.cs
new file mode 100644
index 0000000..f275a3f
--- /dev/null
+++ b/test/Apache.Arrow.Operations.Tests/Shredding/ShredNullRowTests.cs
@@ -0,0 +1,164 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System.Collections.Generic;
+using Apache.Arrow;
+using Apache.Arrow.Operations.Shredding;
+using Apache.Arrow.Scalars.Variant;
+using Xunit;
+
+namespace Apache.Arrow.Operations.Tests.Shredding
+{
+ /// <summary>
+ /// Tests for SQL-NULL rows (a null <see cref="VariantValue"/>?) flowing
through
+ /// inference, shredding and array assembly, and staying distinct from a
present
+ /// <see cref="VariantValue.Null"/>.
+ /// </summary>
+ public class ShredNullRowTests
+ {
+ private static VariantValue Obj(int a) => VariantValue.FromObject(
+ new Dictionary<string, VariantValue> { ["a"] =
VariantValue.FromInt32(a) });
+
+ private static VariantArray ShredAndBuild(IReadOnlyList<VariantValue?>
values, ShredSchema schema)
+ {
+ (byte[] metadata, IReadOnlyList<ShredResult> rows) =
VariantShredder.Shred(values, schema);
+ return ShreddedVariantArrayBuilder.Build(schema, metadata, rows);
+ }
+
+ private static void AssertRows(IReadOnlyList<VariantValue?> expected,
VariantArray array)
+ {
+ Assert.Equal(expected.Count, array.Length);
+ for (int i = 0; i < expected.Count; i++)
+ {
+ if (expected[i].HasValue)
+ {
+ Assert.False(array.IsNull(i), $"row {i} should be valid");
+ Assert.Equal(expected[i].Value,
array.GetLogicalVariantValue(i));
+ }
+ else
+ {
+ Assert.True(array.IsNull(i), $"row {i} should be null");
+ }
+ }
+ }
+
+ [Fact]
+ public void SqlNullRow_IsDistinctFromVariantNull()
+ {
+ var values = new List<VariantValue?> { Obj(1), null,
VariantValue.Null, Obj(3) };
+
+ ShredSchema schema = new ShredSchemaInferer().Infer(values,
ShredOptions.Default);
+ VariantArray array = ShredAndBuild(values, schema);
+
+ Assert.Equal(ShredType.Object, schema.TypedValueType);
+ Assert.Equal(1, array.NullCount);
+ Assert.Equal(1, array.StorageArray.NullCount);
+ AssertRows(values, array);
+ Assert.False(array.IsNull(2));
+ Assert.True(array.GetLogicalVariantValue(2).IsNull);
+ }
+
+ [Fact]
+ public void SqlNullRow_Unshredded()
+ {
+ var values = new List<VariantValue?> {
VariantValue.FromString("x"), null, VariantValue.FromInt32(5) };
+ AssertRows(values, ShredAndBuild(values,
ShredSchema.Unshredded()));
+ }
+
+ [Fact]
+ public void SqlNullRow_Primitive()
+ {
+ var values = new List<VariantValue?> { null,
VariantValue.FromInt32(1), VariantValue.FromString("residual"), null };
+ VariantArray array = ShredAndBuild(values,
ShredSchema.Primitive(ShredType.Int32));
+ Assert.Equal(2, array.NullCount);
+ AssertRows(values, array);
+ }
+
+ [Fact]
+ public void SqlNullRow_Array()
+ {
+ var values = new List<VariantValue?>
+ {
+ VariantValue.FromArray(VariantValue.FromInt32(1),
VariantValue.FromInt32(2)),
+ null,
+ VariantValue.FromArray(VariantValue.FromInt32(3)),
+ };
+ AssertRows(values, ShredAndBuild(values,
ShredSchema.ForArray(ShredSchema.Primitive(ShredType.Int32))));
+ }
+
+ [Fact]
+ public void AllRowsNull()
+ {
+ var values = new List<VariantValue?> { null, null };
+ ShredSchema schema = new ShredSchemaInferer().Infer(values);
+ Assert.Equal(ShredType.None, schema.TypedValueType);
+
+ VariantArray array = ShredAndBuild(values, schema);
+ Assert.Equal(2, array.NullCount);
+ AssertRows(values, array);
+ }
+
+ [Fact]
+ public void NoNullRows_ProducesNoValidityBuffer()
+ {
+ var values = new List<VariantValue?> { Obj(1), Obj(2) };
+ VariantArray array = ShredAndBuild(values, new
ShredSchemaInferer().Infer(values));
+ Assert.Equal(0, array.NullCount);
+ Assert.True(array.StorageArray.NullBitmapBuffer.IsEmpty);
+ }
+
+ [Fact]
+ public void SlicedArray_KeepsValidityAligned()
+ {
+ var values = new List<VariantValue?> { Obj(1), null, Obj(3), null,
Obj(5) };
+ VariantArray array = ShredAndBuild(values, new
ShredSchemaInferer().Infer(values));
+
+ var sliced = (VariantArray)ArrowArrayFactory.Slice(array, 1, 3);
+ AssertRows(new List<VariantValue?> { null, Obj(3), null }, sliced);
+ }
+
+ [Fact]
+ public void Infer_IgnoresNullRows()
+ {
+ // With nulls counted, Int32 would appear in only a third of the
rows.
+ var values = new List<VariantValue?> { VariantValue.FromInt32(1),
null, null, null, VariantValue.FromInt32(2), null };
+ ShredSchema schema = new ShredSchemaInferer().Infer(values);
+ Assert.Equal(ShredType.Int32, schema.TypedValueType);
+ }
+
+ [Fact]
+ public void Shred_ReturnsNullEntryForNullRow()
+ {
+ var values = new List<VariantValue?> { Obj(1), null };
+ (byte[] metadata, IReadOnlyList<ShredResult> rows) =
VariantShredder.Shred(values, ShredSchema.Unshredded());
+ Assert.NotNull(metadata);
+ Assert.NotNull(rows[0]);
+ Assert.Null(rows[1]);
+ }
+
+ [Fact]
+ public void Reconstruct_RoundTripsNullRows()
+ {
+ var values = new List<VariantValue?> { Obj(1), null,
VariantValue.Null, VariantValue.FromString("s") };
+ ShredSchema schema = new ShredSchemaInferer().Infer(values);
+ (byte[] metadata, IReadOnlyList<ShredResult> rows) =
VariantShredder.Shred(values, schema);
+
+ for (int i = 0; i < values.Count; i++)
+ {
+ Assert.Equal(values[i], VariantUnshredder.Reconstruct(rows[i],
schema, metadata));
+ }
+ }
+ }
+}