Skip to content

Commit f57b512

Browse files
authored
test: add unit tests for read/write pipeline and schema converter (#382)
## Summary Add 52 unit tests across 6 new test classes covering previously untested core components in the read pipeline, write pipeline, and schema conversion layer. ## Motivation Several critical classes in the read and write pipelines had zero direct unit test coverage, relying entirely on integration tests for validation. This makes it harder to catch regressions in query optimization (filter/limit/topN push-down), write mode switching (append vs overwrite), staged commit lifecycle, and schema type mapping. ## New Test Classes ### Read Pipeline - **LanceScanBuilderTest** (19 tests) — Tests all 6 Spark push-down interfaces: - `pruneColumns`: schema update and empty schema - `pushFilters`: all-supported filters, mixed supported/unsupported split, empty array, disabled via `pushDownFilters=false` config, skips `Array<Struct>` schemas ([lance#3578](lance-format/lance#3578)) - `pushLimit`, `pushOffset` (rejects multi-fragment), `isPartiallyPushed` - `pushTopN`: enabled/disabled by config, rejects non-`FieldReference` expressions - `pushAggregation`: `COUNT(*)` from metadata vs scanner fallback with filters, rejects `GROUP BY` and non-`COUNT` aggregations - `build()`: returns `LanceScan` vs `LanceLocalScan` for metadata-based `COUNT(*)` - **LanceScanTest** (5 tests) — Tests scan execution: - `readSchema()` returns original schema or count schema when aggregation is pushed - `planInputPartitions()` returns non-empty partitions, propagates filters and limit to `LanceInputPartition` - **LanceSplitTest** (3 tests) — Tests partition planning: - `planScan()` returns splits with resolved version for snapshot isolation - Each split maps to a single fragment - Deprecated `generateLanceSplits()` backward compatibility ### Write Pipeline - **SparkWriteTest** (4 tests) — Tests write interface contract: - `build()` returns `SparkWrite`, `toBatch()` returns `LanceBatchWrite` - `toStreaming()` throws `UnsupportedOperationException` - `truncate()` switches write mode from `APPEND` to `OVERWRITE` - **StagedCommitTest** (4 tests) — Tests staged commit lifecycle with real datasets: - Commit new table creates dataset on disk - Commit existing table overwrites with empty fragments - Abort new table (without namespace) and existing table release resources ### Schema Conversion - **SchemaConverterTest** (17 tests) — Tests Spark-to-Arrow type mapping: - `toJsonArrowSchema`: 11 primitive types, nullability, `ArrayType`, `FixedSizeList` (with metadata), `StructType`, `MapType`, `DecimalType`, empty schema - `processSchemaWithProperties`: vector/blob/large-varchar metadata injection, null/empty properties passthrough, type validation errors (non-array vector, non-float element, non-binary blob, non-string large-varchar)
1 parent 2a87572 commit f57b512

6 files changed

Lines changed: 1018 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.lance.spark.read;
15+
16+
import org.lance.spark.LanceSparkReadOptions;
17+
import org.lance.spark.TestUtils;
18+
19+
import org.apache.spark.sql.connector.expressions.Expression;
20+
import org.apache.spark.sql.connector.expressions.FieldReference;
21+
import org.apache.spark.sql.connector.expressions.NullOrdering;
22+
import org.apache.spark.sql.connector.expressions.SortDirection;
23+
import org.apache.spark.sql.connector.expressions.SortOrder;
24+
import org.apache.spark.sql.connector.expressions.aggregate.AggregateFunc;
25+
import org.apache.spark.sql.connector.expressions.aggregate.Aggregation;
26+
import org.apache.spark.sql.connector.expressions.aggregate.CountStar;
27+
import org.apache.spark.sql.connector.expressions.aggregate.Sum;
28+
import org.apache.spark.sql.connector.read.Scan;
29+
import org.apache.spark.sql.sources.Filter;
30+
import org.apache.spark.sql.sources.GreaterThan;
31+
import org.apache.spark.sql.sources.IsNotNull;
32+
import org.apache.spark.sql.sources.LessThan;
33+
import org.apache.spark.sql.sources.StringContains;
34+
import org.apache.spark.sql.types.ArrayType;
35+
import org.apache.spark.sql.types.DataTypes;
36+
import org.apache.spark.sql.types.StructField;
37+
import org.apache.spark.sql.types.StructType;
38+
import org.junit.jupiter.api.Test;
39+
40+
import java.util.Collections;
41+
42+
import static org.junit.jupiter.api.Assertions.*;
43+
44+
public class LanceScanBuilderTest {
45+
46+
private static final StructType TEST_SCHEMA = TestUtils.TestTable1Config.schema;
47+
48+
private LanceScanBuilder createBuilder() {
49+
return new LanceScanBuilder(
50+
TEST_SCHEMA,
51+
TestUtils.TestTable1Config.readOptions,
52+
Collections.emptyMap(),
53+
null,
54+
Collections.emptyMap());
55+
}
56+
57+
// --- pruneColumns ---
58+
59+
@Test
60+
public void testPruneColumnsUpdatesSchema() {
61+
LanceScanBuilder builder = createBuilder();
62+
StructType requiredSchema =
63+
new StructType(
64+
new StructField[] {
65+
DataTypes.createStructField("x", DataTypes.LongType, true),
66+
});
67+
builder.pruneColumns(requiredSchema);
68+
Scan scan = builder.build();
69+
assertEquals(requiredSchema, scan.readSchema());
70+
}
71+
72+
@Test
73+
public void testPruneColumnsToEmptySchema() {
74+
LanceScanBuilder builder = createBuilder();
75+
StructType emptySchema = new StructType();
76+
builder.pruneColumns(emptySchema);
77+
Scan scan = builder.build();
78+
assertEquals(emptySchema, scan.readSchema());
79+
}
80+
81+
// --- pushFilters ---
82+
83+
@Test
84+
public void testPushFiltersAllSupported() {
85+
LanceScanBuilder builder = createBuilder();
86+
Filter[] filters =
87+
new Filter[] {
88+
new GreaterThan("x", 1L), new LessThan("y", 10L), new IsNotNull("b"),
89+
};
90+
Filter[] postScanFilters = builder.pushFilters(filters);
91+
assertEquals(0, postScanFilters.length);
92+
assertEquals(3, builder.pushedFilters().length);
93+
}
94+
95+
@Test
96+
public void testPushFiltersMixedSupportedAndUnsupported() {
97+
LanceScanBuilder builder = createBuilder();
98+
// StringContains is not supported for push-down
99+
Filter[] filters =
100+
new Filter[] {
101+
new GreaterThan("x", 1L), new StringContains("b", "test"),
102+
};
103+
Filter[] postScanFilters = builder.pushFilters(filters);
104+
assertEquals(1, postScanFilters.length);
105+
assertInstanceOf(StringContains.class, postScanFilters[0]);
106+
assertEquals(1, builder.pushedFilters().length);
107+
assertInstanceOf(GreaterThan.class, builder.pushedFilters()[0]);
108+
}
109+
110+
@Test
111+
public void testPushFiltersEmptyArray() {
112+
LanceScanBuilder builder = createBuilder();
113+
Filter[] result = builder.pushFilters(new Filter[0]);
114+
assertEquals(0, result.length);
115+
assertEquals(0, builder.pushedFilters().length);
116+
}
117+
118+
@Test
119+
public void testPushFiltersDisabledByConfig() {
120+
LanceSparkReadOptions options =
121+
LanceSparkReadOptions.from(
122+
Collections.singletonMap(LanceSparkReadOptions.CONFIG_PUSH_DOWN_FILTERS, "false"),
123+
TestUtils.TestTable1Config.datasetUri);
124+
LanceScanBuilder builder =
125+
new LanceScanBuilder(TEST_SCHEMA, options, Collections.emptyMap(), null, null);
126+
Filter[] filters = new Filter[] {new GreaterThan("x", 1L)};
127+
Filter[] result = builder.pushFilters(filters);
128+
assertEquals(1, result.length);
129+
assertEquals(0, builder.pushedFilters().length);
130+
}
131+
132+
@Test
133+
public void testPushFiltersSkipsNestedArrayOfStruct() {
134+
// Workaround for https://github.com/lance-format/lance/issues/3578
135+
StructType nestedSchema =
136+
new StructType(
137+
new StructField[] {
138+
DataTypes.createStructField("id", DataTypes.LongType, true),
139+
DataTypes.createStructField(
140+
"items",
141+
new ArrayType(
142+
new StructType(
143+
new StructField[] {
144+
DataTypes.createStructField("name", DataTypes.StringType, true)
145+
}),
146+
true),
147+
true),
148+
});
149+
LanceScanBuilder builder =
150+
new LanceScanBuilder(
151+
nestedSchema,
152+
TestUtils.TestTable1Config.readOptions,
153+
Collections.emptyMap(),
154+
null,
155+
Collections.emptyMap());
156+
Filter[] filters = new Filter[] {new GreaterThan("id", 1L)};
157+
Filter[] result = builder.pushFilters(filters);
158+
assertEquals(1, result.length);
159+
assertEquals(0, builder.pushedFilters().length);
160+
}
161+
162+
// --- pushLimit ---
163+
164+
@Test
165+
public void testPushLimitAlwaysSucceeds() {
166+
LanceScanBuilder builder = createBuilder();
167+
assertTrue(builder.pushLimit(100));
168+
}
169+
170+
// --- pushOffset ---
171+
172+
@Test
173+
public void testPushOffsetRejectsMultiFragmentDataset() {
174+
// TestTable1 has 2 fragments, so offset cannot be pushed
175+
LanceScanBuilder builder = createBuilder();
176+
assertFalse(builder.pushOffset(10));
177+
}
178+
179+
@Test
180+
public void testIsPartiallyPushedAlwaysTrue() {
181+
LanceScanBuilder builder = createBuilder();
182+
assertTrue(builder.isPartiallyPushed());
183+
}
184+
185+
// --- pushTopN ---
186+
187+
@Test
188+
public void testPushTopNEnabledByDefault() {
189+
LanceScanBuilder builder = createBuilder();
190+
SortOrder order = new TestSortOrder("x", SortDirection.ASCENDING, NullOrdering.NULLS_FIRST);
191+
assertTrue(builder.pushTopN(new SortOrder[] {order}, 10));
192+
}
193+
194+
@Test
195+
public void testPushTopNDisabledByConfig() {
196+
LanceSparkReadOptions options =
197+
LanceSparkReadOptions.from(
198+
Collections.singletonMap(LanceSparkReadOptions.CONFIG_TOP_N_PUSH_DOWN, "false"),
199+
TestUtils.TestTable1Config.datasetUri);
200+
LanceScanBuilder builder =
201+
new LanceScanBuilder(TEST_SCHEMA, options, Collections.emptyMap(), null, null);
202+
SortOrder order = new TestSortOrder("x", SortDirection.ASCENDING, NullOrdering.NULLS_FIRST);
203+
assertFalse(builder.pushTopN(new SortOrder[] {order}, 10));
204+
}
205+
206+
@Test
207+
public void testPushTopNRejectsNonFieldReferenceExpression() {
208+
LanceScanBuilder builder = createBuilder();
209+
// A SortOrder whose expression is not a FieldReference should be rejected
210+
SortOrder nonFieldOrder =
211+
new SortOrder() {
212+
@Override
213+
public Expression expression() {
214+
return new Expression() {
215+
@Override
216+
public Expression[] children() {
217+
return new Expression[0];
218+
}
219+
220+
@Override
221+
public String toString() {
222+
return "custom_expression";
223+
}
224+
};
225+
}
226+
227+
@Override
228+
public SortDirection direction() {
229+
return SortDirection.ASCENDING;
230+
}
231+
232+
@Override
233+
public NullOrdering nullOrdering() {
234+
return NullOrdering.NULLS_FIRST;
235+
}
236+
};
237+
assertFalse(builder.pushTopN(new SortOrder[] {nonFieldOrder}, 10));
238+
}
239+
240+
// --- pushAggregation ---
241+
242+
@Test
243+
public void testPushAggregationCountStarFromMetadata() {
244+
LanceScanBuilder builder = createBuilder();
245+
Aggregation countStar =
246+
new Aggregation(new AggregateFunc[] {new CountStar()}, new Expression[] {});
247+
assertTrue(builder.pushAggregation(countStar));
248+
}
249+
250+
@Test
251+
public void testPushAggregationCountStarWithFiltersFallsBackToScanner() {
252+
LanceScanBuilder builder = createBuilder();
253+
builder.pushFilters(new Filter[] {new GreaterThan("x", 0L)});
254+
Aggregation countStar =
255+
new Aggregation(new AggregateFunc[] {new CountStar()}, new Expression[] {});
256+
// With pushed filters, metadata count cannot be used; falls back to scanner-based count
257+
assertTrue(builder.pushAggregation(countStar));
258+
}
259+
260+
@Test
261+
public void testPushAggregationRejectsGroupBy() {
262+
LanceScanBuilder builder = createBuilder();
263+
Aggregation groupedAgg =
264+
new Aggregation(
265+
new AggregateFunc[] {new CountStar()}, new Expression[] {FieldReference.apply("x")});
266+
assertFalse(builder.pushAggregation(groupedAgg));
267+
}
268+
269+
@Test
270+
public void testPushAggregationRejectsNonCountStar() {
271+
LanceScanBuilder builder = createBuilder();
272+
Aggregation sumAgg =
273+
new Aggregation(
274+
new AggregateFunc[] {new Sum(FieldReference.apply("x"), false)}, new Expression[] {});
275+
assertFalse(builder.pushAggregation(sumAgg));
276+
}
277+
278+
// --- build ---
279+
280+
@Test
281+
public void testBuildReturnsLanceScan() {
282+
LanceScanBuilder builder = createBuilder();
283+
Scan scan = builder.build();
284+
assertNotNull(scan);
285+
assertInstanceOf(LanceScan.class, scan);
286+
assertEquals(TEST_SCHEMA, scan.readSchema());
287+
}
288+
289+
@Test
290+
public void testBuildWithCountStarReturnsLocalScan() {
291+
LanceScanBuilder builder = createBuilder();
292+
Aggregation countStar =
293+
new Aggregation(new AggregateFunc[] {new CountStar()}, new Expression[] {});
294+
builder.pushAggregation(countStar);
295+
Scan scan = builder.build();
296+
// Metadata-based COUNT(*) without filters returns LanceLocalScan
297+
assertNotNull(scan);
298+
assertInstanceOf(LanceLocalScan.class, scan);
299+
}
300+
301+
/** Minimal SortOrder implementation for testing pushTopN. */
302+
private static class TestSortOrder implements SortOrder {
303+
private final String columnName;
304+
private final SortDirection direction;
305+
private final NullOrdering nullOrdering;
306+
307+
TestSortOrder(String columnName, SortDirection direction, NullOrdering nullOrdering) {
308+
this.columnName = columnName;
309+
this.direction = direction;
310+
this.nullOrdering = nullOrdering;
311+
}
312+
313+
@Override
314+
public Expression expression() {
315+
return FieldReference.apply(columnName);
316+
}
317+
318+
@Override
319+
public SortDirection direction() {
320+
return direction;
321+
}
322+
323+
@Override
324+
public NullOrdering nullOrdering() {
325+
return nullOrdering;
326+
}
327+
}
328+
}

0 commit comments

Comments
 (0)