Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@

private final CSVReader reader;
private final @Nullable List<@Nullable String> filterValues;
private final @Nullable List<RelDataType> fieldTypes;
private final AtomicBoolean cancelFlag;
private final RowConverter<E> rowConverter;
private @Nullable E current;
Expand Down Expand Up @@ -115,18 +116,25 @@
public CsvEnumerator(Source source, AtomicBoolean cancelFlag,
List<RelDataType> fieldTypes, List<Integer> fields, char separator) {
//noinspection unchecked
this(source, cancelFlag, false, null,
this(source, cancelFlag, false, null, fieldTypes,
(RowConverter<E>) converter(fieldTypes, fields), separator);
}

public CsvEnumerator(Source source, AtomicBoolean cancelFlag, boolean stream,
@Nullable String @Nullable [] filterValues, RowConverter<E> rowConverter,
char separator) {
this(source, cancelFlag, stream, filterValues, null, rowConverter, separator);
}

public CsvEnumerator(Source source, AtomicBoolean cancelFlag, boolean stream,
@Nullable String @Nullable [] filterValues, @Nullable List<RelDataType> fieldTypes,
RowConverter<E> rowConverter, char separator) {
this.cancelFlag = cancelFlag;
this.rowConverter = rowConverter;
this.filterValues =
filterValues == null ? null
: ImmutableNullableList.copyOf(filterValues);
this.fieldTypes = fieldTypes;
try {
if (stream) {
this.reader = new CsvStreamReader(source, separator);
Expand All @@ -139,7 +147,7 @@
}
}

private static RowConverter<?> converter(List<RelDataType> fieldTypes,
static RowConverter<?> converter(List<RelDataType> fieldTypes,

Check failure on line 150 in file/src/main/java/org/apache/calcite/adapter/file/CsvEnumerator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove usage of generic wildcard type.

See more on https://sonarcloud.io/project/issues?id=apache_calcite&issues=AZ8Jst_0UniKS_RltI-z&open=AZ8Jst_0UniKS_RltI-z&pullRequest=5048
List<Integer> fields) {
if (fields.size() == 1) {
final int field = fields.get(0);
Expand Down Expand Up @@ -254,6 +262,19 @@
return new CSVReader(source.reader(), separator);
}

private static boolean objectsEqual(@Nullable Object o1, @Nullable Object o2) {
if (o1 == o2) {
return true;
}
if (o1 == null || o2 == null) {
return false;
}
if (o1 instanceof BigDecimal && o2 instanceof BigDecimal) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this case needed? Doesn't BigDecimal have equals?
If it does, can this become Objects.equals()?

@Diveyam-Mishra Diveyam-Mishra Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The core problem is that BigDecimal violates the intuitive expectation that "same number = equal object":
new BigDecimal("2.0").equals(new BigDecimal("2.00")) // false
new BigDecimal("2.0").compareTo(new BigDecimal("2.00")) == 0 // true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How about using compareTo for everything and using Comaprable for o1 and o2?

return ((BigDecimal) o1).compareTo((BigDecimal) o2) == 0;
}
return o1.equals(o2);
}

@Override public E current() {
return castNonNull(current);
}
Expand Down Expand Up @@ -284,10 +305,17 @@
return false;
}
if (filterValues != null) {
for (int i = 0; i < strings.length; i++) {
for (int i = 0; i < filterValues.size(); i++) {

Check warning on line 308 in file/src/main/java/org/apache/calcite/adapter/file/CsvEnumerator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reduce the total number of break and continue statements in this loop to use at most one.

See more on https://sonarcloud.io/project/issues?id=apache_calcite&issues=AZ8KcxtaFFhzEQA0NmTa&open=AZ8KcxtaFFhzEQA0NmTa&pullRequest=5048
String filterValue = filterValues.get(i);
if (filterValue != null) {
if (!filterValue.equals(strings[i])) {
final @Nullable String fieldValue = field(strings, i);
if (fieldTypes != null && i < fieldTypes.size()) {
Object convertedFilter = rowConverter.convert(fieldTypes.get(i), filterValue);
Object convertedValue = rowConverter.convert(fieldTypes.get(i), fieldValue);
if (!objectsEqual(convertedFilter, convertedValue)) {
continue outer;
}
} else if (!filterValue.equals(fieldValue)) {
continue outer;
}
}
Expand Down Expand Up @@ -329,6 +357,11 @@
return typeFactory.createTypeWithNullability(typeFactory.createSqlType(sqlTypeName), true);
}

/** Returns a field from a CSV row, or null if the row is too short. */
private static @Nullable String field(String[] strings, int index) {
return index < strings.length ? strings[index] : null;
}

/** Row converter.
*
* @param <E> element type */
Expand Down Expand Up @@ -480,7 +513,7 @@
final @Nullable Object[] objects = new Object[fields.size()];
for (int i = 0; i < fields.size(); i++) {
int field = fields.get(i);
objects[i] = convert(fieldTypes.get(field), strings[field]);
objects[i] = convert(fieldTypes.get(field), field(strings, field));
}
return objects;
}
Expand All @@ -490,7 +523,7 @@
objects[0] = System.currentTimeMillis();
for (int i = 0; i < fields.size(); i++) {
int field = fields.get(i);
objects[i + 1] = convert(fieldTypes.get(field), strings[field]);
objects[i + 1] = convert(fieldTypes.get(field), field(strings, field));
}
return objects;
}
Expand All @@ -507,7 +540,7 @@
}

@Override public @Nullable Object convertRow(@Nullable String[] strings) {
return convert(fieldType, strings[fieldIndex]);
return convert(fieldType, field(strings, fieldIndex));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* 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.
*/
package org.apache.calcite.adapter.file;

import org.apache.calcite.plan.RelOptRuleCall;
import org.apache.calcite.plan.RelRule;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.logical.LogicalFilter;
import org.apache.calcite.rex.RexCall;
import org.apache.calcite.rex.RexInputRef;
import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.rex.RexUtil;
import org.apache.calcite.sql.SqlKind;

import org.checkerframework.checker.nullness.qual.Nullable;
import org.immutables.value.Value;

import java.util.ArrayList;
import java.util.List;

/**
* Planner rule that pushes simple equality filter predicates into a
* {@link CsvTableScan}.
*
* <p>Only equality conditions of the form {@code column = literal} can be
* pushed down, because {@link CsvEnumerator} only supports per-column

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could this situation be improved? Is this a fundamental limitation of CsvEnumerator?
Maybe we need a more powerful enumerator.
In principle I think any predicate of the current row value should work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My current plan is to introduce a CsvFilter abstraction to represent the subset of filters that can be pushed down (initially AND, OR, = and <>, including null comparisons). Rather than encoding pushdown state as column-value arrays, the planner will build a CsvFilter tree, serialize it, and pass the serialized representation through CsvTableScan/CsvTranslatableTable to CsvEnumerator, where it will be deserialized and evaluated against each row.

The CsvFilter classes are intended to be a lightweight data model representing pushdownable predicates, while evaluation, serialization/deserialization, and pretty-printing remain separate concerns. This keeps the representation extensible for additional pushdown operators in the future without requiring further changes to the transport mechanism between planning and execution.
There is one more option which is to do exactly what spark does compile the filter all the way down to actual bytecode but in my opinion thats a bit overkill

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Calcite already includes a compiler which generates the enumerable code, why can't the same compiler generate the filter implementation as a compiled Java function? Then you can support arbitrary functions.

* equality filtering via its {@code filterValues} array.
* Any predicates that cannot be pushed down (e.g., range comparisons,
* {@code OR} expressions, or comparisons involving expressions) are left
* as a residual {@link LogicalFilter} above the scan.
*
* <p>This rule fires after {@link CsvProjectTableScanRule} so that the
* scan's field list may already be a subset of the full table fields.
* Filter column references are mapped back through the projected field list
* to the original column indices expected by {@link CsvEnumerator}.
*
* @see FileRules#FILTER_SCAN
*/
@Value.Enclosing
public class CsvFilterTableScanRule
extends RelRule<CsvFilterTableScanRule.Config> {

/** Creates a CsvFilterTableScanRule. */
protected CsvFilterTableScanRule(Config config) {
super(config);
}

@Override public void onMatch(RelOptRuleCall call) {
final LogicalFilter filter = call.rel(0);
final CsvTableScan scan = call.rel(1);

// filterValues is indexed by the *full* table column index so that
// CsvEnumerator can match against the raw CSV row directly.
final int fullFieldCount = scan.getTable().getRowType().getFieldCount();
final @Nullable String[] filterValues = new String[fullFieldCount];

// Partition the filter condition into predicates we can push down
// (simple column = literal equality) and predicates we cannot.
final List<RexNode> residualFilters = new ArrayList<>();
decomposeFilter(filter.getCondition(), scan.fields, filterValues, residualFilters);

// Only transform if at least one predicate could be pushed into the scan.
boolean anyPushed = false;
for (String v : filterValues) {
if (v != null) {
anyPushed = true;
break;
}
}
if (!anyPushed) {
return;
}

// Build a new scan that carries the pushed-down filter values.
final CsvTableScan newScan =
new CsvTableScan(scan.getCluster(), scan.getTable(), scan.csvTable,
scan.fields, filterValues);

// If there are residual predicates that could not be pushed down,
// keep a LogicalFilter node above the new scan to evaluate them.
final RelNode result;
if (residualFilters.isEmpty()) {
result = newScan;
} else {
final RexNode residual =
RexUtil.composeConjunction(scan.getCluster().getRexBuilder(), residualFilters);
result = filter.copy(filter.getTraitSet(), newScan, residual);
}
call.transformTo(result);
}

/**
* Decomposes a filter condition into pushable equality predicates and
* non-pushable residual predicates.
*
* <p>AND conjunctions are recursively decomposed so that pushable
* sub-predicates can be extracted even when some siblings are not pushable.
*
* <p>A predicate {@code col = literal} is pushable when:
* <ul>
* <li>The left operand is a {@link RexInputRef} (optionally wrapped in
* a {@code CAST}), referring to one of the projected columns in
* {@code projectedFields}.</li>
* <li>The right operand is a {@link RexLiteral}.</li>
* <li>No earlier predicate has already set a value for the same column
* (first match wins).</li>
* </ul>
*
* @param condition The filter condition to decompose
* @param projectedFields The field projection array from the current
* {@link CsvTableScan} ({@code scan.fields}), used
* to map projected column indices back to full-table
* column indices for {@code filterValues}
* @param filterValues Output: per-full-table-column equality values;
* populated in place for pushable predicates
* @param residualFilters Output: predicates that could not be pushed down
*/
private static void decomposeFilter(RexNode condition, int[] projectedFields,
@Nullable String[] filterValues, List<RexNode> residualFilters) {
if (condition.isA(SqlKind.AND)) {
// Decompose AND: process each conjunct independently so that pushable
// sub-predicates can be separated from non-pushable ones.
for (RexNode operand : ((RexCall) condition).getOperands()) {
decomposeFilter(operand, projectedFields, filterValues, residualFilters);
}
} else if (condition.isA(SqlKind.EQUALS)) {
if (!tryPushEquality((RexCall) condition, projectedFields, filterValues)) {
residualFilters.add(condition);
}
} else {
// Any other predicate kind (OR, comparison, function call, etc.)
// cannot be pushed into the CsvEnumerator's string-equality filter.
residualFilters.add(condition);
}
}

/**
* Attempts to push a single equality predicate into {@code filterValues}.
*
* @return {@code true} if the predicate was pushed; {@code false} if it
* must remain as a residual filter
*/
private static boolean tryPushEquality(RexCall call, int[] projectedFields,
@Nullable String[] filterValues) {
RexNode left = call.getOperands().get(0);
final RexNode right = call.getOperands().get(1);

// Unwrap a CAST on the left side (e.g., CAST(col AS VARCHAR) = 'val').
if (left.isA(SqlKind.CAST)) {
left = ((RexCall) left).getOperands().get(0);
}

if (!(left instanceof RexInputRef) || !(right instanceof RexLiteral)) {
return false;
}

// The RexInputRef index is relative to the *projected* row type of the
// current scan, not to the full table row type. Map it back.
final int projectedIndex = ((RexInputRef) left).getIndex();
if (projectedIndex >= projectedFields.length) {
return false;
}
final int fullTableIndex = projectedFields[projectedIndex];

// First equality for this column wins; ignore duplicates.
if (filterValues[fullTableIndex] != null) {
return false;
}

final RexLiteral literal = (RexLiteral) right;
final Object value;
switch (literal.getTypeName()) {
case CHAR:
case VARCHAR:
value = literal.getValueAs(String.class);
break;
default:
value = literal.getValueAs(Comparable.class);
}
if (value == null) {
// NULL literals cannot be represented by CsvEnumerator's filter values.
return false;
}

filterValues[fullTableIndex] = value.toString();
return true;
}

/** Rule configuration. */
@Value.Immutable(singleton = false)
public interface Config extends RelRule.Config {
Config DEFAULT = ImmutableCsvFilterTableScanRule.Config.builder()
.withOperandSupplier(b0 ->
b0.operand(LogicalFilter.class).oneInput(b1 ->
b1.operand(CsvTableScan.class).noInputs()))
.build();

@Override default CsvFilterTableScanRule toRule() {
return new CsvFilterTableScanRule(this);
}
}
}
Loading
Loading