This is an automated email from the ASF dual-hosted git repository.
SemyonSinchenko pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-graphar.git
The following commit(s) were added to refs/heads/main by this push:
new 6f97f015 feat(java): make a declined pushdown visible instead of
silent (#972)
6f97f015 is described below
commit 6f97f015719ee2586b50116092f59db9fa1945d5
Author: alex <[email protected]>
AuthorDate: Wed Sep 16 11:46:49 2026 +0300
feat(java): make a declined pushdown visible instead of silent (#972)
* feat(java): let a read request name what it wants pushed down
A physical read is not only a URI. A caller knows which columns it needs,
which source rows, which values, and how many rows are enough - and a
backend
that learns all four up front can skip work that a caller would otherwise
have
to discard after the fact.
Add the request half of the read API: an ordered projection, a half-open row
range, an AND conjunction of row predicates over immutable scalars, and a
row
limit. A request also reports which of those hints it actually carries, so a
backend can answer for each one instead of guessing.
A column is addressed through ColumnRef, not a bare String. A reference is
unresolved by construction because a request exists before its file is
opened;
a backend binds it with Schema.resolve, which matches the exact name and
refuses an unknown or ambiguous column instead of picking the first hit.
A Filter is a closed predicate tree: a column comparison, or AND, OR and NOT
over predicates, built through one named factory per operator so a null
check
cannot be handed a value and a comparison cannot be handed null. A backend
walks
the tree through a Visitor with one callback per node kind, so adding a node
kind is a compile error in every reader rather than a silently skipped
branch.
Constraint: filter literals stay immutable and lossless. NaN, infinity, and
sub-millisecond timestamps are refused rather than silently compared
against a
value a stored format cannot represent.
* feat(java): make a declined pushdown visible instead of silent
A backend that cannot apply a hint must still return the same rows as one
that
can. That leaves a caller unable to tell an applied filter from an ignored
one,
which matters: an ignored row range or limit changes cost, not results.
Add the reader half of the read API. A read returns a cursor together with a
report that names every requested hint as applied or declined, and the
result
refuses to be built unless the report accounts for exactly the hints the
request carried - no gaps, no invented ones.
Directive: declining a pushdown must not change the rows or the schema a
read
returns. The contract test asserts that a reader declining everything
produces
the same output as one applying everything.
---
.../main/java/org/apache/graphar/io/ColumnRef.java | 68 ++++
.../org/apache/graphar/io/ComparisonOperator.java | 32 ++
.../main/java/org/apache/graphar/io/Filter.java | 331 ++++++++++++++++++++
.../main/java/org/apache/graphar/io/Literal.java | 80 +++++
.../java/org/apache/graphar/io/PhysicalReader.java | 35 +++
.../java/org/apache/graphar/io/Projection.java | 97 ++++++
.../java/org/apache/graphar/io/ReadCapability.java | 28 ++
.../java/org/apache/graphar/io/ReadReport.java | 78 +++++
.../java/org/apache/graphar/io/ReadRequest.java | 169 ++++++++++
.../java/org/apache/graphar/io/ReadResult.java | 57 ++++
.../main/java/org/apache/graphar/io/RowRange.java | 62 ++++
.../main/java/org/apache/graphar/io/Schema.java | 30 ++
.../java/org/apache/graphar/io/ColumnRefTest.java | 68 ++++
.../java/org/apache/graphar/io/FilterTest.java | 170 ++++++++++
.../java/org/apache/graphar/io/LiteralTest.java | 68 ++++
.../graphar/io/PhysicalReaderContractTest.java | 346 +++++++++++++++++++++
.../java/org/apache/graphar/io/ProjectionTest.java | 82 +++++
.../java/org/apache/graphar/io/ReadReportTest.java | 74 +++++
.../org/apache/graphar/io/ReadRequestTest.java | 112 +++++++
.../java/org/apache/graphar/io/ReadResultTest.java | 86 +++++
.../java/org/apache/graphar/io/RowRangeTest.java | 56 ++++
21 files changed, 2129 insertions(+)
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnRef.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnRef.java
new file mode 100644
index 00000000..13599b12
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnRef.java
@@ -0,0 +1,68 @@
+/*
+ * 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.graphar.io;
+
+import java.util.Objects;
+
+/**
+ * An unresolved reference to one top-level column of a physical file,
addressed by its exact name.
+ *
+ * <p>A request is built before the file it targets is opened, so a reference
can only name a
+ * column; binding it to a position and a {@link ColumnType} happens when a
physical reader resolves
+ * it against the file {@link Schema} with {@link Schema#resolve(ColumnRef)}.
That resolution is
+ * exact: names are compared verbatim, a name that is absent from the schema
is an error, and a name
+ * that matches more than one field is an error rather than a first-match
guess. A reference carries
+ * no qualifier because the physical boundary reads exactly one file and one
schema; mapping a
+ * qualified logical property onto a physical column is the caller's job.
+ */
+public final class ColumnRef {
+ private final String name;
+
+ private ColumnRef(String name) {
+ this.name = name;
+ }
+
+ /** References the top-level column called exactly {@code name}. */
+ public static ColumnRef of(String name) {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("A column name cannot be
blank.");
+ }
+ return new ColumnRef(name);
+ }
+
+ /** Returns the exact column name this reference resolves by. */
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof ColumnRef && name.equals(((ColumnRef)
other).name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(name);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ComparisonOperator.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ComparisonOperator.java
new file mode 100644
index 00000000..1c12c9a8
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ComparisonOperator.java
@@ -0,0 +1,32 @@
+/*
+ * 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.graphar.io;
+
+/** The simple comparison operations supported by an IO filter hint. */
+public enum ComparisonOperator {
+ EQUAL,
+ NOT_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ IS_NULL,
+ IS_NOT_NULL
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Filter.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Filter.java
new file mode 100644
index 00000000..af2a5599
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Filter.java
@@ -0,0 +1,331 @@
+/*
+ * 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.graphar.io;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An inspectable row predicate: a column comparison, or a boolean combination
of predicates. The
+ * hierarchy is closed; a physical reader walks it with a {@link Visitor} and
gets one call per node
+ * kind, so a reader that does not push a node down still evaluates it as
fallback.
+ *
+ * <p>A physical reader must validate a comparison literal against the
column's {@link ColumnType}:
+ * BOOLEAN uses Boolean; integer and floating kinds use their matching boxed
Java types; STRING uses
+ * String; DATE uses LocalDate; and TIMESTAMP_MILLIS uses millisecond-precise
Instant. Other types
+ * cannot be compared by this contract. Comparison operands must have the same
declared type; a type
+ * mismatch is invalid rather than a coercion. Null values never match a
comparison, including
+ * NOT_EQUAL; use {@link #isNull} or {@link #isNotNull} for null tests.
Ordered STRING comparisons
+ * use {@link String#compareTo(String)}, and DATE and TIMESTAMP_MILLIS use
their natural ordering.
+ * Readers must reject an invalid comparison before returning a result,
whether the filter is pushed
+ * down or evaluated as fallback.
+ *
+ * <p>Boolean nodes use two-valued logic over the comparison results above: a
comparison on a null
+ * value is false, so {@code not(equal(c, v))} keeps rows where {@code c} is
null while {@code
+ * notEqual(c, v)} does not.
+ */
+public abstract class Filter {
+ Filter() {}
+
+ /** Keeps rows whose {@code column} equals {@code value}. */
+ public static Filter equal(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.EQUAL, value);
+ }
+
+ /** Keeps non-null rows whose {@code column} differs from {@code value}. */
+ public static Filter notEqual(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.NOT_EQUAL, value);
+ }
+
+ /** Keeps rows whose {@code column} is strictly less than {@code value}. */
+ public static Filter lessThan(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.LESS_THAN, value);
+ }
+
+ /** Keeps rows whose {@code column} is less than or equal to {@code
value}. */
+ public static Filter lessThanOrEqual(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.LESS_THAN_OR_EQUAL,
value);
+ }
+
+ /** Keeps rows whose {@code column} is strictly greater than {@code
value}. */
+ public static Filter greaterThan(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.GREATER_THAN, value);
+ }
+
+ /** Keeps rows whose {@code column} is greater than or equal to {@code
value}. */
+ public static Filter greaterThanOrEqual(ColumnRef column, Literal value) {
+ return comparison(column, ComparisonOperator.GREATER_THAN_OR_EQUAL,
value);
+ }
+
+ /** Keeps rows whose {@code column} is null. */
+ public static Filter isNull(ColumnRef column) {
+ return new Comparison(column, ComparisonOperator.IS_NULL, null);
+ }
+
+ /** Keeps rows whose {@code column} is not null. */
+ public static Filter isNotNull(ColumnRef column) {
+ return new Comparison(column, ComparisonOperator.IS_NOT_NULL, null);
+ }
+
+ /** Keeps rows that satisfy every operand; nested conjunctions are
flattened. */
+ public static Filter and(Filter first, Filter second, Filter... rest) {
+ return and(operands(first, second, rest));
+ }
+
+ /** Keeps rows that satisfy every operand; nested conjunctions are
flattened. */
+ public static Filter and(List<Filter> operands) {
+ List<Filter> flat = flatten(operands, And.class);
+ return flat.size() == 1 ? flat.get(0) : new And(flat);
+ }
+
+ /** Keeps rows that satisfy at least one operand; nested disjunctions are
flattened. */
+ public static Filter or(Filter first, Filter second, Filter... rest) {
+ return or(operands(first, second, rest));
+ }
+
+ /** Keeps rows that satisfy at least one operand; nested disjunctions are
flattened. */
+ public static Filter or(List<Filter> operands) {
+ List<Filter> flat = flatten(operands, Or.class);
+ return flat.size() == 1 ? flat.get(0) : new Or(flat);
+ }
+
+ /** Keeps rows that do not satisfy {@code operand}; a double negation is
removed. */
+ public static Filter not(Filter operand) {
+ Objects.requireNonNull(operand, "A filter operand cannot be null.");
+ return operand instanceof Not ? ((Not) operand).operand() : new
Not(operand);
+ }
+
+ /** Returns {@code and(this, other)}. */
+ public final Filter and(Filter other) {
+ return and(this, other);
+ }
+
+ /** Returns {@code or(this, other)}. */
+ public final Filter or(Filter other) {
+ return or(this, other);
+ }
+
+ /** Returns {@code not(this)}. */
+ public final Filter negate() {
+ return not(this);
+ }
+
+ /** Dispatches on this node's kind. */
+ public abstract <R> R accept(Visitor<R> visitor);
+
+ private static Filter comparison(ColumnRef column, ComparisonOperator
operator, Literal value) {
+ return new Comparison(
+ column,
+ operator,
+ Objects.requireNonNull(value, "A comparison value cannot be
null."));
+ }
+
+ private static List<Filter> operands(Filter first, Filter second, Filter[]
rest) {
+ List<Filter> operands = new ArrayList<>(2 + (rest == null ? 0 :
rest.length));
+ operands.add(first);
+ operands.add(second);
+ if (rest != null) {
+ operands.addAll(Arrays.asList(rest));
+ }
+ return operands;
+ }
+
+ private static List<Filter> flatten(List<Filter> operands, Class<? extends
Filter> same) {
+ if (operands == null || operands.isEmpty()) {
+ throw new IllegalArgumentException("A boolean filter needs at
least one operand.");
+ }
+ List<Filter> flat = new ArrayList<>(operands.size());
+ for (Filter operand : operands) {
+ Objects.requireNonNull(operand, "A filter operand cannot be
null.");
+ if (same.isInstance(operand)) {
+ flat.addAll(((Junction) operand).operands());
+ } else {
+ flat.add(operand);
+ }
+ }
+ return List.copyOf(flat);
+ }
+
+ /** One callback per node kind; a reader implements all four. */
+ public interface Visitor<R> {
+ R comparison(Comparison filter);
+
+ R and(And filter);
+
+ R or(Or filter);
+
+ R not(Not filter);
+ }
+
+ /** A single column compared against a literal, or tested for null. */
+ public static final class Comparison extends Filter {
+ private final ColumnRef column;
+ private final ComparisonOperator operator;
+ private final Literal value;
+
+ private Comparison(ColumnRef column, ComparisonOperator operator,
Literal value) {
+ this.column = Objects.requireNonNull(column, "A filter column
cannot be null.");
+ this.operator = Objects.requireNonNull(operator, "Filter operator
cannot be null.");
+ this.value = value;
+ }
+
+ /** Returns the column this comparison inspects. */
+ public ColumnRef column() {
+ return column;
+ }
+
+ /** Returns the comparison applied. */
+ public ComparisonOperator operator() {
+ return operator;
+ }
+
+ /** Returns the comparison value, which is absent for null checks. */
+ public Optional<Literal> value() {
+ return Optional.ofNullable(value);
+ }
+
+ @Override
+ public <R> R accept(Visitor<R> visitor) {
+ return visitor.comparison(this);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Comparison)) {
+ return false;
+ }
+ Comparison that = (Comparison) other;
+ return column.equals(that.column)
+ && operator == that.operator
+ && Objects.equals(value, that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(column, operator, value);
+ }
+
+ @Override
+ public String toString() {
+ return value == null
+ ? column + " " + operator
+ : column + " " + operator + " " + value.value();
+ }
+ }
+
+ /** A boolean node over two or more operands. */
+ public abstract static class Junction extends Filter {
+ private final List<Filter> operands;
+
+ private Junction(List<Filter> operands) {
+ this.operands = operands;
+ }
+
+ /** Returns the immutable operands in the given order. */
+ public final List<Filter> operands() {
+ return operands;
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ return other != null
+ && getClass() == other.getClass()
+ && operands.equals(((Junction) other).operands);
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(getClass(), operands);
+ }
+ }
+
+ /** Every operand must hold. */
+ public static final class And extends Junction {
+ private And(List<Filter> operands) {
+ super(operands);
+ }
+
+ @Override
+ public <R> R accept(Visitor<R> visitor) {
+ return visitor.and(this);
+ }
+
+ @Override
+ public String toString() {
+ return "AND" + operands();
+ }
+ }
+
+ /** At least one operand must hold. */
+ public static final class Or extends Junction {
+ private Or(List<Filter> operands) {
+ super(operands);
+ }
+
+ @Override
+ public <R> R accept(Visitor<R> visitor) {
+ return visitor.or(this);
+ }
+
+ @Override
+ public String toString() {
+ return "OR" + operands();
+ }
+ }
+
+ /** The operand must not hold. */
+ public static final class Not extends Filter {
+ private final Filter operand;
+
+ private Not(Filter operand) {
+ this.operand = operand;
+ }
+
+ /** Returns the negated predicate. */
+ public Filter operand() {
+ return operand;
+ }
+
+ @Override
+ public <R> R accept(Visitor<R> visitor) {
+ return visitor.not(this);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Not && operand.equals(((Not)
other).operand);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(Not.class, operand);
+ }
+
+ @Override
+ public String toString() {
+ return "NOT[" + operand + "]";
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Literal.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Literal.java
new file mode 100644
index 00000000..9140c32e
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Literal.java
@@ -0,0 +1,80 @@
+/*
+ * 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.graphar.io;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.Objects;
+
+/** An immutable scalar used by a {@link Filter} comparison. */
+public final class Literal {
+ private final Object value;
+
+ private Literal(Object value) {
+ this.value = value;
+ }
+
+ /**
+ * Wraps a supported immutable scalar: {@link Boolean}, numeric boxed
primitives, {@link
+ * String}, {@link LocalDate}, or millisecond-precise {@link Instant}.
+ */
+ public static Literal of(Object value) {
+ Objects.requireNonNull(value, "A literal value cannot be null.");
+ if (!(value instanceof Boolean)
+ && !(value instanceof Byte)
+ && !(value instanceof Short)
+ && !(value instanceof Integer)
+ && !(value instanceof Long)
+ && !(value instanceof Float)
+ && !(value instanceof Double)
+ && !(value instanceof String)
+ && !(value instanceof LocalDate)
+ && !(value instanceof Instant)) {
+ throw new IllegalArgumentException(
+ "A literal must be a Boolean, numeric boxed primitive,
String, LocalDate, or Instant.");
+ }
+ if (value instanceof Float && !Float.isFinite((Float) value)) {
+ throw new IllegalArgumentException("A floating point literal must
be finite.");
+ }
+ if (value instanceof Double && !Double.isFinite((Double) value)) {
+ throw new IllegalArgumentException("A floating point literal must
be finite.");
+ }
+ if (value instanceof Instant && ((Instant) value).getNano() %
1_000_000 != 0) {
+ throw new IllegalArgumentException(
+ "An Instant literal must have millisecond precision.");
+ }
+ return new Literal(value);
+ }
+
+ /** Returns this literal's immutable scalar value. */
+ public Object value() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Literal && value.equals(((Literal)
other).value);
+ }
+
+ @Override
+ public int hashCode() {
+ return value.hashCode();
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalReader.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalReader.java
new file mode 100644
index 00000000..683e92e5
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalReader.java
@@ -0,0 +1,35 @@
+/*
+ * 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.graphar.io;
+
+import java.io.IOException;
+import java.util.Set;
+
+/** A format-specific reader behind the GraphAr physical IO boundary. */
+public interface PhysicalReader {
+ /** Returns the immutable set of optimizations this reader can apply
physically. */
+ Set<ReadCapability> capabilities();
+
+ /**
+ * Reads an operation exactly. Capabilities declined in the returned
report must still be
+ * handled with semantics-preserving fallback.
+ */
+ ReadResult read(ReadRequest request) throws IOException;
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Projection.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Projection.java
new file mode 100644
index 00000000..a0103c39
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Projection.java
@@ -0,0 +1,97 @@
+/*
+ * 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.graphar.io;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/** An ordered set of requested output columns. */
+public final class Projection {
+ private static final Projection ALL_COLUMNS = new Projection(true,
List.of());
+
+ private final boolean allColumns;
+ private final List<ColumnRef> columns;
+
+ private Projection(boolean allColumns, List<ColumnRef> columns) {
+ this.allColumns = allColumns;
+ this.columns = columns;
+ }
+
+ /** Requests every available column. */
+ public static Projection all() {
+ return ALL_COLUMNS;
+ }
+
+ /** Requests the supplied columns in order. */
+ public static Projection of(ColumnRef... columns) {
+ if (columns == null) {
+ throw new IllegalArgumentException("A projection must contain at
least one column.");
+ }
+ return of(Arrays.asList(columns));
+ }
+
+ /** Requests the supplied columns in order. */
+ public static Projection of(List<ColumnRef> columns) {
+ if (columns == null || columns.isEmpty()) {
+ throw new IllegalArgumentException("A projection must contain at
least one column.");
+ }
+ List<ColumnRef> copy = new ArrayList<>(columns.size());
+ Set<ColumnRef> seen = new HashSet<>();
+ for (ColumnRef column : columns) {
+ Objects.requireNonNull(column, "A projection column cannot be
null.");
+ if (!seen.add(column)) {
+ throw new IllegalArgumentException(
+ "Projection contains duplicate column: " + column);
+ }
+ copy.add(column);
+ }
+ return new Projection(false, List.copyOf(copy));
+ }
+
+ /** Returns whether this projection requests every available column. */
+ public boolean isAllColumns() {
+ return allColumns;
+ }
+
+ /** Returns the requested columns, or an empty list when all columns are
requested. */
+ public List<ColumnRef> columns() {
+ return columns;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Projection)) {
+ return false;
+ }
+ Projection that = (Projection) other;
+ return allColumns == that.allColumns && columns.equals(that.columns);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(allColumns, columns);
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadCapability.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadCapability.java
new file mode 100644
index 00000000..581dc86d
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadCapability.java
@@ -0,0 +1,28 @@
+/*
+ * 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.graphar.io;
+
+/** A physical optimization that an IO reader may apply to a request. */
+public enum ReadCapability {
+ PROJECTION,
+ ROW_RANGE,
+ FILTER,
+ LIMIT
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadReport.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadReport.java
new file mode 100644
index 00000000..23400057
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadReport.java
@@ -0,0 +1,78 @@
+/*
+ * 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.graphar.io;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+
+/** Per-read accounting of physical hints applied or declined by a backend. */
+public final class ReadReport {
+ private final Set<ReadCapability> applied;
+ private final Set<ReadCapability> declined;
+
+ public ReadReport(Set<ReadCapability> applied, Set<ReadCapability>
declined) {
+ EnumSet<ReadCapability> appliedCopy = copyOf(applied);
+ EnumSet<ReadCapability> declinedCopy = copyOf(declined);
+ EnumSet<ReadCapability> overlap = EnumSet.copyOf(appliedCopy);
+ overlap.retainAll(declinedCopy);
+ if (!overlap.isEmpty()) {
+ throw new IllegalArgumentException(
+ "A read capability cannot be both applied and declined.");
+ }
+ this.applied = Collections.unmodifiableSet(appliedCopy);
+ this.declined = Collections.unmodifiableSet(declinedCopy);
+ }
+
+ /** Returns capabilities physically applied by this read. */
+ public Set<ReadCapability> applied() {
+ return applied;
+ }
+
+ /** Returns requested capabilities handled through semantics-preserving
fallback. */
+ public Set<ReadCapability> declined() {
+ return declined;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof ReadReport)) {
+ return false;
+ }
+ ReadReport that = (ReadReport) other;
+ return applied.equals(that.applied) && declined.equals(that.declined);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(applied, declined);
+ }
+
+ private static EnumSet<ReadCapability> copyOf(Set<ReadCapability>
capabilities) {
+ Objects.requireNonNull(capabilities, "Read capabilities cannot be
null.");
+ return capabilities.isEmpty()
+ ? EnumSet.noneOf(ReadCapability.class)
+ : EnumSet.copyOf(capabilities);
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadRequest.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadRequest.java
new file mode 100644
index 00000000..0eb15d0f
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadRequest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.graphar.io;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+
+/**
+ * An immutable physical read operation. Filters are combined with logical AND
and may require a
+ * backend to read columns that are not in the requested output projection.
+ */
+public final class ReadRequest {
+ private final URI uri;
+ private final Projection projection;
+ private final RowRange rowRange;
+ private final List<Filter> filters;
+ private final Long limit;
+
+ private ReadRequest(Builder builder) {
+ this.uri = Objects.requireNonNull(builder.uri, "A read URI cannot be
null.");
+ this.projection = builder.projection;
+ this.rowRange = builder.rowRange;
+ this.filters = List.copyOf(builder.filters);
+ this.limit = builder.limit;
+ }
+
+ /** Starts a request for {@code uri} with every available column selected.
*/
+ public static Builder builder(URI uri) {
+ return new Builder(uri);
+ }
+
+ /** Returns the logical location of the physical input. */
+ public URI uri() {
+ return uri;
+ }
+
+ /** Returns the requested output columns. */
+ public Projection projection() {
+ return projection;
+ }
+
+ /** Returns the optional half-open source-row range. */
+ public Optional<RowRange> rowRange() {
+ return Optional.ofNullable(rowRange);
+ }
+
+ /** Returns the immutable, ordered conjunction of filter hints. */
+ public List<Filter> filters() {
+ return filters;
+ }
+
+ /** Returns the optional maximum number of output rows; zero is a valid
limit. */
+ public OptionalLong limit() {
+ return limit == null ? OptionalLong.empty() : OptionalLong.of(limit);
+ }
+
+ /** Returns every physical optimization requested by this operation. */
+ public Set<ReadCapability> requestedCapabilities() {
+ EnumSet<ReadCapability> capabilities =
EnumSet.noneOf(ReadCapability.class);
+ if (!projection.isAllColumns()) {
+ capabilities.add(ReadCapability.PROJECTION);
+ }
+ if (rowRange != null) {
+ capabilities.add(ReadCapability.ROW_RANGE);
+ }
+ if (!filters.isEmpty()) {
+ capabilities.add(ReadCapability.FILTER);
+ }
+ if (limit != null) {
+ capabilities.add(ReadCapability.LIMIT);
+ }
+ return Collections.unmodifiableSet(capabilities);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof ReadRequest)) {
+ return false;
+ }
+ ReadRequest that = (ReadRequest) other;
+ return uri.equals(that.uri)
+ && projection.equals(that.projection)
+ && Objects.equals(rowRange, that.rowRange)
+ && filters.equals(that.filters)
+ && Objects.equals(limit, that.limit);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uri, projection, rowRange, filters, limit);
+ }
+
+ /** Builder for immutable {@link ReadRequest} values. */
+ public static final class Builder {
+ private final URI uri;
+ private Projection projection = Projection.all();
+ private RowRange rowRange;
+ private List<Filter> filters = List.of();
+ private Long limit;
+
+ private Builder(URI uri) {
+ this.uri = Objects.requireNonNull(uri, "A read URI cannot be
null.");
+ }
+
+ /** Replaces the output projection. */
+ public Builder projection(Projection projection) {
+ this.projection = Objects.requireNonNull(projection, "A projection
cannot be null.");
+ return this;
+ }
+
+ /** Restricts the read to a half-open source-row range. */
+ public Builder rowRange(RowRange rowRange) {
+ this.rowRange = Objects.requireNonNull(rowRange, "A row range
cannot be null.");
+ return this;
+ }
+
+ /** Replaces the ordered conjunction of filter hints. */
+ public Builder filters(List<Filter> filters) {
+ Objects.requireNonNull(filters, "Filters cannot be null.");
+ List<Filter> copy = new ArrayList<>(filters.size());
+ for (Filter filter : filters) {
+ copy.add(Objects.requireNonNull(filter, "A filter cannot be
null."));
+ }
+ this.filters = List.copyOf(copy);
+ return this;
+ }
+
+ /** Limits returned rows after any requested filtering. */
+ public Builder limit(long limit) {
+ if (limit < 0) {
+ throw new IllegalArgumentException("A read limit cannot be
negative.");
+ }
+ this.limit = limit;
+ return this;
+ }
+
+ /** Builds an immutable read operation. */
+ public ReadRequest build() {
+ return new ReadRequest(this);
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadResult.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadResult.java
new file mode 100644
index 00000000..71755756
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ReadResult.java
@@ -0,0 +1,57 @@
+/*
+ * 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.graphar.io;
+
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+
+/** A cursor and the complete physical-capability accounting for one read
operation. */
+public final class ReadResult {
+ private final BatchCursor cursor;
+ private final ReadReport report;
+
+ /**
+ * Creates a result whose report completely accounts for every requested
physical capability.
+ */
+ public ReadResult(ReadRequest request, BatchCursor cursor, ReadReport
report) {
+ Objects.requireNonNull(request, "A read request cannot be null.");
+ this.cursor = Objects.requireNonNull(cursor, "A batch cursor cannot be
null.");
+ this.report = Objects.requireNonNull(report, "A read report cannot be
null.");
+ EnumSet<ReadCapability> accounted =
EnumSet.noneOf(ReadCapability.class);
+ accounted.addAll(report.applied());
+ accounted.addAll(report.declined());
+ Set<ReadCapability> requested = request.requestedCapabilities();
+ if (!accounted.equals(requested)) {
+ throw new IllegalArgumentException(
+ "A read report must account for each requested capability
and no others.");
+ }
+ }
+
+ /** Returns the batches from this operation. */
+ public BatchCursor cursor() {
+ return cursor;
+ }
+
+ /** Returns which hints were physically applied or declined. */
+ public ReadReport report() {
+ return report;
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/RowRange.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/RowRange.java
new file mode 100644
index 00000000..dea5940f
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/RowRange.java
@@ -0,0 +1,62 @@
+/*
+ * 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.graphar.io;
+
+import java.util.Objects;
+
+/** A half-open physical row range: {@code [startInclusive, endExclusive)}. */
+public final class RowRange {
+ private final long startInclusive;
+ private final long endExclusive;
+
+ public RowRange(long startInclusive, long endExclusive) {
+ if (startInclusive < 0 || endExclusive < startInclusive) {
+ throw new IllegalArgumentException(
+ "A row range must satisfy 0 <= startInclusive <=
endExclusive.");
+ }
+ this.startInclusive = startInclusive;
+ this.endExclusive = endExclusive;
+ }
+
+ public long startInclusive() {
+ return startInclusive;
+ }
+
+ public long endExclusive() {
+ return endExclusive;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof RowRange)) {
+ return false;
+ }
+ RowRange that = (RowRange) other;
+ return startInclusive == that.startInclusive && endExclusive ==
that.endExclusive;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(startInclusive, endExclusive);
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Schema.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Schema.java
index c7b347f1..f1d68a36 100644
--- a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Schema.java
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Schema.java
@@ -41,6 +41,36 @@ public final class Schema {
return fields;
}
+ /**
+ * Resolves {@code column} to its zero-based physical index by exact name.
+ *
+ * @throws IllegalArgumentException when no field or more than one field
carries that name
+ */
+ public int resolve(ColumnRef column) {
+ Objects.requireNonNull(column, "A column reference cannot be null.");
+ int found = -1;
+ for (int index = 0; index < fields.size(); index++) {
+ if (!fields.get(index).name().equals(column.name())) {
+ continue;
+ }
+ if (found >= 0) {
+ throw new IllegalArgumentException(
+ "Column "
+ + column
+ + " is ambiguous: it matches fields "
+ + found
+ + " and "
+ + index
+ + ".");
+ }
+ found = index;
+ }
+ if (found < 0) {
+ throw new IllegalArgumentException("Column " + column + " is not
in the schema.");
+ }
+ return found;
+ }
+
@Override
public boolean equals(Object other) {
return other instanceof Schema && fields.equals(((Schema)
other).fields);
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnRefTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnRefTest.java
new file mode 100644
index 00000000..4ae37949
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnRefTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.util.List;
+import org.junit.Test;
+
+public class ColumnRefTest {
+ private static final Field ID = new Field("id",
ColumnType.of(ColumnType.Kind.INT32), false);
+ private static final Field NAME =
+ new Field("name", ColumnType.of(ColumnType.Kind.STRING), true);
+
+ @Test
+ public void comparesByExactName() {
+ assertEquals(ColumnRef.of("id"), ColumnRef.of("id"));
+ assertEquals(ColumnRef.of("id").hashCode(),
ColumnRef.of("id").hashCode());
+ assertNotEquals(ColumnRef.of("id"), ColumnRef.of("Id"));
+ assertNotEquals(ColumnRef.of("id"), ColumnRef.of("id "));
+ assertEquals("id", ColumnRef.of("id").name());
+ }
+
+ @Test
+ public void refusesABlankName() {
+ assertThrows(IllegalArgumentException.class, () -> ColumnRef.of(null));
+ assertThrows(IllegalArgumentException.class, () -> ColumnRef.of(""));
+ assertThrows(IllegalArgumentException.class, () -> ColumnRef.of(" "));
+ }
+
+ @Test
+ public void resolvesToThePhysicalIndexOfTheOnlyMatchingField() {
+ Schema schema = new Schema(List.of(ID, NAME));
+
+ assertEquals(0, schema.resolve(ColumnRef.of("id")));
+ assertEquals(1, schema.resolve(ColumnRef.of("name")));
+ }
+
+ @Test
+ public void refusesToGuessAnUnknownOrAmbiguousColumn() {
+ Schema unique = new Schema(List.of(ID, NAME));
+ Schema duplicated = new Schema(List.of(ID, NAME, NAME));
+
+ assertThrows(IllegalArgumentException.class, () ->
unique.resolve(ColumnRef.of("age")));
+ assertThrows(IllegalArgumentException.class, () ->
unique.resolve(ColumnRef.of("ID")));
+ assertEquals(0, duplicated.resolve(ColumnRef.of("id")));
+ assertThrows(
+ IllegalArgumentException.class, () ->
duplicated.resolve(ColumnRef.of("name")));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/FilterTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/FilterTest.java
new file mode 100644
index 00000000..7bb47610
--- /dev/null
+++ b/maven-projects/io-api/src/test/java/org/apache/graphar/io/FilterTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.util.List;
+import org.junit.Test;
+
+public class FilterTest {
+ private static final ColumnRef NAME = ColumnRef.of("name");
+ private static final ColumnRef AGE = ColumnRef.of("age");
+ private static final Filter ADULT = Filter.greaterThanOrEqual(AGE,
Literal.of(18));
+ private static final Filter NAMED = Filter.isNotNull(NAME);
+ private static final Filter ALICE = Filter.equal(NAME,
Literal.of("alice"));
+
+ @Test
+ public void retainsAnImmutableScalarComparison() {
+ Filter.Comparison filter = (Filter.Comparison) ALICE;
+
+ assertEquals(NAME, filter.column());
+ assertEquals(ComparisonOperator.EQUAL, filter.operator());
+ assertEquals(Literal.of("alice"), filter.value().get());
+ assertEquals(filter, Filter.equal(ColumnRef.of("name"),
Literal.of("alice")));
+ assertEquals(filter.hashCode(), Filter.equal(NAME,
Literal.of("alice")).hashCode());
+ }
+
+ @Test
+ public void namesEveryComparisonOperator() {
+ assertEquals(ComparisonOperator.NOT_EQUAL,
operator(Filter.notEqual(NAME, Literal.of(1))));
+ assertEquals(ComparisonOperator.LESS_THAN,
operator(Filter.lessThan(NAME, Literal.of(1))));
+ assertEquals(
+ ComparisonOperator.LESS_THAN_OR_EQUAL,
+ operator(Filter.lessThanOrEqual(NAME, Literal.of(1))));
+ assertEquals(
+ ComparisonOperator.GREATER_THAN,
operator(Filter.greaterThan(NAME, Literal.of(1))));
+ assertEquals(
+ ComparisonOperator.GREATER_THAN_OR_EQUAL,
+ operator(Filter.greaterThanOrEqual(NAME, Literal.of(1))));
+ }
+
+ @Test
+ public void nullChecksCarryNoValue() {
+ Filter.Comparison isNull = (Filter.Comparison) Filter.isNull(NAME);
+ Filter.Comparison isNotNull = (Filter.Comparison) NAMED;
+
+ assertEquals(ComparisonOperator.IS_NULL, isNull.operator());
+ assertEquals(ComparisonOperator.IS_NOT_NULL, isNotNull.operator());
+ assertFalse(isNull.value().isPresent());
+ assertFalse(isNotNull.value().isPresent());
+ assertEquals(Filter.isNull(NAME), isNull);
+ assertNotEquals(isNull, isNotNull);
+ }
+
+ @Test
+ public void composesWithAndOrAndNot() {
+ Filter filter = NAMED.and(ADULT).or(ALICE.negate());
+
+ Filter.Or or = (Filter.Or) filter;
+ Filter.And and = (Filter.And) or.operands().get(0);
+ Filter.Not not = (Filter.Not) or.operands().get(1);
+ assertEquals(List.of(NAMED, ADULT), and.operands());
+ assertEquals(ALICE, not.operand());
+ assertEquals(filter, Filter.or(Filter.and(NAMED, ADULT),
Filter.not(ALICE)));
+ assertEquals(
+ filter.hashCode(),
+ Filter.or(Filter.and(NAMED, ADULT),
Filter.not(ALICE)).hashCode());
+ assertNotEquals(Filter.and(NAMED, ADULT), Filter.or(NAMED, ADULT));
+ assertNotEquals(Filter.and(NAMED, ADULT), Filter.and(ADULT, NAMED));
+ }
+
+ @Test
+ public void flattensSameKindJunctionsAndCollapsesTrivialOnes() {
+ assertEquals(Filter.and(NAMED, ADULT, ALICE),
NAMED.and(ADULT).and(ALICE));
+ assertEquals(Filter.and(NAMED, ADULT, ALICE),
NAMED.and(ADULT.and(ALICE)));
+ assertEquals(Filter.or(NAMED, ADULT, ALICE),
NAMED.or(ADULT).or(ALICE));
+ assertEquals(
+ List.of(NAMED, Filter.or(ADULT, ALICE)),
+ ((Filter.And) NAMED.and(ADULT.or(ALICE))).operands());
+ assertSame(NAMED, Filter.and(List.of(NAMED)));
+ assertSame(NAMED, Filter.or(List.of(NAMED)));
+ assertSame(NAMED, NAMED.negate().negate());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> ((Filter.And) NAMED.and(ADULT)).operands().add(ALICE));
+ }
+
+ @Test
+ public void visitsEveryNodeKind() {
+ Filter filter = NAMED.and(ADULT).or(ALICE.negate());
+
+ String rendered =
+ filter.accept(
+ new Filter.Visitor<String>() {
+ @Override
+ public String comparison(Filter.Comparison node) {
+ return node.column().name();
+ }
+
+ @Override
+ public String and(Filter.And node) {
+ return "(" + join(node, " & ") + ")";
+ }
+
+ @Override
+ public String or(Filter.Or node) {
+ return "(" + join(node, " | ") + ")";
+ }
+
+ @Override
+ public String not(Filter.Not node) {
+ return "!" + node.operand().accept(this);
+ }
+
+ private String join(Filter.Junction node, String
glue) {
+ StringBuilder out = new StringBuilder();
+ for (Filter operand : node.operands()) {
+ if (out.length() > 0) {
+ out.append(glue);
+ }
+ out.append(operand.accept(this));
+ }
+ return out.toString();
+ }
+ });
+
+ assertEquals("((name & age) | !name)", rendered);
+ assertTrue(filter.toString().startsWith("OR["));
+ }
+
+ @Test
+ public void rejectsAmbiguousOrLossyComparisonValues() {
+ assertThrows(IllegalArgumentException.class, () ->
Literal.of(Double.NaN));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
Literal.of(Instant.parse("2025-01-01T00:00:00.000000001Z")));
+ assertThrows(NullPointerException.class, () -> Filter.equal(NAME,
null));
+ assertThrows(NullPointerException.class, () -> Filter.isNull(null));
+ assertThrows(IllegalArgumentException.class, () ->
Filter.and(List.of()));
+ assertThrows(IllegalArgumentException.class, () ->
Filter.or(List.of()));
+ assertThrows(NullPointerException.class, () -> Filter.and(NAMED,
null));
+ assertThrows(NullPointerException.class, () -> Filter.not(null));
+ }
+
+ private static ComparisonOperator operator(Filter filter) {
+ return ((Filter.Comparison) filter).operator();
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/LiteralTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/LiteralTest.java
new file mode 100644
index 00000000..ed34c6ec
--- /dev/null
+++ b/maven-projects/io-api/src/test/java/org/apache/graphar/io/LiteralTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import org.junit.Test;
+
+public class LiteralTest {
+ @Test
+ public void carriesEverySupportedScalarUnchanged() {
+ assertEquals(Boolean.TRUE, Literal.of(Boolean.TRUE).value());
+ assertEquals((byte) 1, Literal.of((byte) 1).value());
+ assertEquals((short) 2, Literal.of((short) 2).value());
+ assertEquals(3, Literal.of(3).value());
+ assertEquals(4L, Literal.of(4L).value());
+ assertEquals(5.5f, Literal.of(5.5f).value());
+ assertEquals(6.5d, Literal.of(6.5d).value());
+ assertEquals("seven", Literal.of("seven").value());
+ assertEquals(LocalDate.of(2024, 1, 31), Literal.of(LocalDate.of(2024,
1, 31)).value());
+ assertEquals(Instant.ofEpochMilli(8),
Literal.of(Instant.ofEpochMilli(8)).value());
+ }
+
+ @Test
+ public void comparesByValue() {
+ assertEquals(Literal.of(4L), Literal.of(4L));
+ assertEquals(Literal.of(4L).hashCode(), Literal.of(4L).hashCode());
+ assertNotEquals(Literal.of(4L), Literal.of(4));
+ assertNotEquals(Literal.of(4L), "4");
+ }
+
+ @Test
+ public void refusesAValueAFilterCannotComparePhysically() {
+ assertThrows(NullPointerException.class, () -> Literal.of(null));
+ assertThrows(IllegalArgumentException.class, () -> Literal.of(new
byte[] {1}));
+ assertThrows(IllegalArgumentException.class, () ->
Literal.of(Float.NaN));
+ assertThrows(IllegalArgumentException.class, () ->
Literal.of(Double.POSITIVE_INFINITY));
+ }
+
+ @Test
+ public void refusesATimestampFinerThanTheFormatCanStore() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> Literal.of(Instant.ofEpochSecond(1, 1_500_000)));
+ Literal.of(Instant.ofEpochSecond(1, 2_000_000));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalReaderContractTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalReaderContractTest.java
new file mode 100644
index 00000000..87629b65
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalReaderContractTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import org.junit.Test;
+
+public class PhysicalReaderContractTest {
+ @Test
+ public void reportsTheExactIntersectionOfRequestedAndSupportedHints()
throws IOException {
+ ReadRequest request = requestWithEveryHint();
+ RecordingReader reader =
+ new RecordingReader(EnumSet.of(ReadCapability.PROJECTION,
ReadCapability.LIMIT));
+
+ ReadResult result = reader.read(request);
+
+ assertSame(request, reader.request());
+ assertEquals(
+ EnumSet.of(ReadCapability.PROJECTION, ReadCapability.LIMIT),
+ result.report().applied());
+ assertEquals(
+ EnumSet.of(ReadCapability.ROW_RANGE, ReadCapability.FILTER),
+ result.report().declined());
+ }
+
+ @Test
+ public void decliningAllHintsIsObservableAndStillAValidExactRead() throws
IOException {
+ ReadRequest request = requestWithEveryHint();
+ RecordingReader reader = new
RecordingReader(EnumSet.noneOf(ReadCapability.class));
+
+ ReadResult result = reader.read(request);
+
+ assertEquals(Collections.emptySet(), result.report().applied());
+ assertEquals(EnumSet.allOf(ReadCapability.class),
result.report().declined());
+ }
+
+ @Test
+ public void declinedHintsPreserveRowsAndSchema() throws IOException {
+ ReadRequest request = requestForRows();
+ InMemoryReader allCapable = new
InMemoryReader(EnumSet.allOf(ReadCapability.class));
+ InMemoryReader noCapability = new
InMemoryReader(EnumSet.noneOf(ReadCapability.class));
+
+ Snapshot applied = snapshot(allCapable.read(request));
+ Snapshot declined = snapshot(noCapability.read(request));
+
+ assertEquals(applied, declined);
+ assertEquals(List.of("id", "name"), declined.columnNames);
+ assertEquals(List.of(List.of(2, "beta")), declined.rows);
+ }
+
+ @Test
+ public void resultRejectsIncompleteOrUnrequestedReports() {
+ ReadRequest request =
ReadRequest.builder(URI.create("file:/input")).build();
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new ReadResult(
+ request,
+ EmptyBatchCursor.INSTANCE,
+ new ReadReport(
+ EnumSet.of(ReadCapability.LIMIT),
Collections.emptySet())));
+ }
+
+ private static ReadRequest requestWithEveryHint() {
+ return ReadRequest.builder(URI.create("file:/input"))
+ .projection(Projection.of(ColumnRef.of("id")))
+ .rowRange(new RowRange(10, 20))
+ .filters(List.of(Filter.greaterThan(ColumnRef.of("id"),
Literal.of(7))))
+ .limit(4)
+ .build();
+ }
+
+ private static ReadRequest requestForRows() {
+ return ReadRequest.builder(URI.create("memory:/input"))
+ .projection(Projection.of(ColumnRef.of("id"),
ColumnRef.of("name")))
+ .rowRange(new RowRange(1, 5))
+
.filters(List.of(Filter.greaterThanOrEqual(ColumnRef.of("age"),
Literal.of(18))))
+ .limit(1)
+ .build();
+ }
+
+ private static Snapshot snapshot(ReadResult result) throws IOException {
+ List<String> columnNames = null;
+ List<List<Object>> rows = new ArrayList<>();
+ try (BatchCursor cursor = result.cursor()) {
+ while (cursor.next()) {
+ RecordBatch batch = cursor.batch();
+ if (columnNames == null) {
+ columnNames = new ArrayList<>();
+ for (Field field : batch.schema().fields()) {
+ columnNames.add(field.name());
+ }
+ }
+ for (int index = 0; index < batch.rowCount(); index++) {
+ List<Object> values = new ArrayList<>();
+ for (int column = 0; column < batch.columnCount();
column++) {
+ values.add(batch.column(column).getObject(index));
+ }
+ rows.add(List.copyOf(values));
+ }
+ }
+ }
+ return new Snapshot(List.copyOf(columnNames), List.copyOf(rows));
+ }
+
+ private static final class RecordingReader implements PhysicalReader {
+ private final Set<ReadCapability> capabilities;
+ private ReadRequest request;
+
+ private RecordingReader(Set<ReadCapability> capabilities) {
+ this.capabilities =
Collections.unmodifiableSet(EnumSet.copyOf(capabilities));
+ }
+
+ @Override
+ public Set<ReadCapability> capabilities() {
+ return capabilities;
+ }
+
+ @Override
+ public ReadResult read(ReadRequest request) {
+ this.request = request;
+ EnumSet<ReadCapability> applied =
EnumSet.noneOf(ReadCapability.class);
+ applied.addAll(request.requestedCapabilities());
+ applied.retainAll(capabilities);
+ EnumSet<ReadCapability> declined =
EnumSet.noneOf(ReadCapability.class);
+ declined.addAll(request.requestedCapabilities());
+ declined.removeAll(applied);
+ return new ReadResult(
+ request, EmptyBatchCursor.INSTANCE, new
ReadReport(applied, declined));
+ }
+
+ private ReadRequest request() {
+ return request;
+ }
+ }
+
+ private static final class InMemoryReader implements PhysicalReader {
+ private static final Schema INPUT_SCHEMA =
+ new Schema(
+ List.of(
+ new Field("id",
ColumnType.of(ColumnType.Kind.INT32), false),
+ new Field("name",
ColumnType.of(ColumnType.Kind.STRING), false),
+ new Field("age",
ColumnType.of(ColumnType.Kind.INT32), false)));
+ private static final Schema OUTPUT_SCHEMA =
+ new Schema(
+ List.of(
+ new Field("id",
ColumnType.of(ColumnType.Kind.INT32), false),
+ new Field("name",
ColumnType.of(ColumnType.Kind.STRING), false)));
+ private static final List<List<Object>> INPUT_ROWS =
+ List.of(
+ List.of(1, "alpha", 17),
+ List.of(2, "beta", 20),
+ List.of(3, "gamma", 25),
+ List.of(4, "delta", 16),
+ List.of(5, "epsilon", 18));
+
+ private final Set<ReadCapability> capabilities;
+
+ private InMemoryReader(Set<ReadCapability> capabilities) {
+ this.capabilities =
Collections.unmodifiableSet(EnumSet.copyOf(capabilities));
+ }
+
+ @Override
+ public Set<ReadCapability> capabilities() {
+ return capabilities;
+ }
+
+ @Override
+ public ReadResult read(ReadRequest request) {
+ EnumSet<ReadCapability> applied = reportedApplied(request);
+ EnumSet<ReadCapability> declined = reportedDeclined(request,
applied);
+ List<List<Object>> projected = new ArrayList<>();
+ RowRange range = request.rowRange().orElse(new RowRange(0,
INPUT_ROWS.size()));
+ for (long index = range.startInclusive(); index <
range.endExclusive(); index++) {
+ List<Object> row = INPUT_ROWS.get((int) index);
+ if (((Integer) row.get(2)) >= 18) {
+ projected.add(List.of(row.get(0), row.get(1)));
+ if (request.limit().isPresent()
+ && projected.size() ==
request.limit().getAsLong()) {
+ break;
+ }
+ }
+ }
+ return new ReadResult(
+ request,
+ new SingleBatchCursor(listRecordBatch(OUTPUT_SCHEMA,
projected)),
+ new ReadReport(applied, declined));
+ }
+
+ private EnumSet<ReadCapability> reportedApplied(ReadRequest request) {
+ EnumSet<ReadCapability> applied =
EnumSet.noneOf(ReadCapability.class);
+ applied.addAll(request.requestedCapabilities());
+ applied.retainAll(capabilities);
+ return applied;
+ }
+
+ private EnumSet<ReadCapability> reportedDeclined(
+ ReadRequest request, Set<ReadCapability> applied) {
+ EnumSet<ReadCapability> declined =
EnumSet.noneOf(ReadCapability.class);
+ declined.addAll(request.requestedCapabilities());
+ declined.removeAll(applied);
+ return declined;
+ }
+ }
+
+ private static RecordBatch listRecordBatch(Schema schema,
List<List<Object>> rows) {
+ int columnCount = schema.fields().size();
+ List<ValueVector> columns = new ArrayList<>(columnCount);
+ for (int column = 0; column < columnCount; column++) {
+ List<Object> values = new ArrayList<>(rows.size());
+ for (List<Object> row : rows) {
+ values.add(row.get(column));
+ }
+ columns.add(new ListValueVector(schema.fields().get(column),
values));
+ }
+ return new VectorRecordBatch(schema, columns, rows.size());
+ }
+
+ private static final class ListValueVector implements ValueVector {
+ private final Field field;
+ private final List<Object> values;
+
+ private ListValueVector(Field field, List<Object> values) {
+ this.field = field;
+ this.values = List.copyOf(values);
+ }
+
+ @Override
+ public Field field() {
+ return field;
+ }
+
+ @Override
+ public int valueCount() {
+ return values.size();
+ }
+
+ @Override
+ public boolean isNull(int index) {
+ return values.get(index) == null;
+ }
+
+ @Override
+ public Object getObject(int index) {
+ return values.get(index);
+ }
+ }
+
+ private static final class SingleBatchCursor implements BatchCursor {
+ private final RecordBatch batch;
+ private boolean advanced;
+
+ private SingleBatchCursor(RecordBatch batch) {
+ this.batch = batch;
+ }
+
+ @Override
+ public boolean next() {
+ if (advanced) {
+ return false;
+ }
+ advanced = true;
+ return true;
+ }
+
+ @Override
+ public RecordBatch batch() {
+ if (!advanced) {
+ throw new IllegalStateException("The cursor has no current
batch.");
+ }
+ return batch;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ private static final class Snapshot {
+ private final List<String> columnNames;
+ private final List<List<Object>> rows;
+
+ private Snapshot(List<String> columnNames, List<List<Object>> rows) {
+ this.columnNames = columnNames;
+ this.rows = rows;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof Snapshot)) {
+ return false;
+ }
+ Snapshot that = (Snapshot) other;
+ return columnNames.equals(that.columnNames) &&
rows.equals(that.rows);
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(columnNames, rows);
+ }
+ }
+
+ private enum EmptyBatchCursor implements BatchCursor {
+ INSTANCE;
+
+ @Override
+ public boolean next() {
+ return false;
+ }
+
+ @Override
+ public RecordBatch batch() {
+ throw new IllegalStateException("The cursor has no current
batch.");
+ }
+
+ @Override
+ public void close() {}
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ProjectionTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ProjectionTest.java
new file mode 100644
index 00000000..7a673a79
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ProjectionTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+public class ProjectionTest {
+ private static final ColumnRef ID = ColumnRef.of("id");
+ private static final ColumnRef NAME = ColumnRef.of("name");
+
+ @Test
+ public void requestsEveryColumnWithoutNamingOne() {
+ Projection projection = Projection.all();
+
+ assertTrue(projection.isAllColumns());
+ assertTrue(projection.columns().isEmpty());
+ }
+
+ @Test
+ public void keepsTheRequestedColumnsInTheGivenOrder() {
+ Projection projection = Projection.of(Arrays.asList(ID, NAME));
+
+ assertFalse(projection.isAllColumns());
+ assertEquals(Arrays.asList(ID, NAME), projection.columns());
+ assertEquals(projection, Projection.of(ID, NAME));
+ }
+
+ @Test
+ public void doesNotFollowLaterEditsToTheSuppliedList() {
+ List<ColumnRef> columns = new ArrayList<>(Arrays.asList(ID, NAME));
+ Projection projection = Projection.of(columns);
+ columns.add(ColumnRef.of("extra"));
+
+ assertEquals(2, projection.columns().size());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> projection.columns().add(ColumnRef.of("injected")));
+ }
+
+ @Test
+ public void comparesByRequestedColumnsAndOrder() {
+ assertEquals(Projection.of(ID), Projection.of(List.of(ID)));
+ assertEquals(Projection.of(ID).hashCode(),
Projection.of(ID).hashCode());
+ assertNotEquals(Projection.of(ID, NAME), Projection.of(NAME, ID));
+ assertNotEquals(Projection.all(), Projection.of(ID));
+ }
+
+ @Test
+ public void refusesAProjectionThatSelectsNothingOrRepeatsAColumn() {
+ assertThrows(IllegalArgumentException.class, () ->
Projection.of((List<ColumnRef>) null));
+ assertThrows(IllegalArgumentException.class, () ->
Projection.of((ColumnRef[]) null));
+ assertThrows(IllegalArgumentException.class, () ->
Projection.of(List.of()));
+ assertThrows(IllegalArgumentException.class, () -> Projection.of(ID,
ID));
+ assertThrows(NullPointerException.class, () ->
Projection.of(Arrays.asList(ID, null)));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadReportTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadReportTest.java
new file mode 100644
index 00000000..bdebf334
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadReportTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.util.EnumSet;
+import java.util.Set;
+import org.junit.Test;
+
+public class ReadReportTest {
+ @Test
+ public void snapshotsAndSeparatesCapabilities() {
+ Set<ReadCapability> applied = EnumSet.of(ReadCapability.PROJECTION);
+ ReadReport report = new ReadReport(applied,
EnumSet.of(ReadCapability.FILTER));
+ applied.clear();
+
+ assertEquals(EnumSet.of(ReadCapability.PROJECTION), report.applied());
+ assertEquals(EnumSet.of(ReadCapability.FILTER), report.declined());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> report.applied().add(ReadCapability.LIMIT));
+ }
+
+ @Test
+ public void comparesByAppliedAndDeclinedSets() {
+ ReadReport report =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
EnumSet.of(ReadCapability.FILTER));
+ ReadReport same =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
EnumSet.of(ReadCapability.FILTER));
+ ReadReport swapped =
+ new ReadReport(
+ EnumSet.of(ReadCapability.FILTER),
EnumSet.of(ReadCapability.PROJECTION));
+
+ assertEquals(report, same);
+ assertEquals(report.hashCode(), same.hashCode());
+ assertNotEquals(report, swapped);
+ assertEquals(Set.of(report), Set.of(same));
+ }
+
+ @Test
+ public void rejectsOverlappingOrNullCapabilitySets() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
+ EnumSet.of(ReadCapability.PROJECTION)));
+ assertThrows(
+ NullPointerException.class,
+ () -> new ReadReport(null,
EnumSet.noneOf(ReadCapability.class)));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadRequestTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadRequestTest.java
new file mode 100644
index 00000000..b7d157dd
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadRequestTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import org.junit.Test;
+
+public class ReadRequestTest {
+ @Test
+ public void snapshotsAllReadHints() {
+ List<ColumnRef> columns =
+ new ArrayList<>(List.of(ColumnRef.of("dst"),
ColumnRef.of("weight")));
+ List<Filter> filters =
+ new ArrayList<>(
+ List.of(
+ Filter.greaterThanOrEqual(
+ ColumnRef.of("weight"),
Literal.of(1.5D))));
+
+ ReadRequest request =
+ ReadRequest.builder(URI.create("file:/dataset/part0"))
+ .projection(Projection.of(columns))
+ .rowRange(new RowRange(4, 9))
+ .filters(filters)
+ .limit(0)
+ .build();
+
+ columns.clear();
+ filters.clear();
+
+ assertEquals(
+ List.of(ColumnRef.of("dst"), ColumnRef.of("weight")),
+ request.projection().columns());
+ assertEquals(1, request.filters().size());
+ assertEquals(new RowRange(4, 9), request.rowRange().get());
+ assertTrue(request.limit().isPresent());
+ assertEquals(0L, request.limit().getAsLong());
+ assertEquals(EnumSet.allOf(ReadCapability.class),
request.requestedCapabilities());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () ->
request.filters().add(Filter.isNotNull(ColumnRef.of("weight"))));
+ }
+
+ @Test
+ public void defaultsDoNotRequestPushdown() {
+ ReadRequest request =
ReadRequest.builder(URI.create("memory:/input")).build();
+
+ assertTrue(request.projection().isAllColumns());
+ assertFalse(request.rowRange().isPresent());
+ assertFalse(request.limit().isPresent());
+ assertTrue(request.requestedCapabilities().isEmpty());
+ }
+
+ @Test
+ public void rejectsInvalidHints() {
+ assertThrows(IllegalArgumentException.class, () -> new RowRange(-1,
0));
+ assertThrows(IllegalArgumentException.class, () -> new RowRange(2, 1));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
ReadRequest.builder(URI.create("file:/input")).limit(-1));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> Projection.of(ColumnRef.of("id"), ColumnRef.of("id")));
+ assertThrows(
+ IllegalArgumentException.class, () -> Literal.of(new
StringBuilder("mutable")));
+ }
+
+ @Test
+ public void comparesByEveryHint() {
+ ReadRequest request = request().build();
+
+ assertEquals(request, request().build());
+ assertEquals(request.hashCode(), request().build().hashCode());
+ assertNotEquals(request, request().limit(2).build());
+ assertNotEquals(request, request().rowRange(new RowRange(0,
1)).build());
+ assertNotEquals(request, request().filters(List.of()).build());
+ assertNotEquals(request,
request().projection(Projection.all()).build());
+ assertNotEquals(request,
ReadRequest.builder(URI.create("file:/other")).build());
+ }
+
+ private static ReadRequest.Builder request() {
+ return ReadRequest.builder(URI.create("file:/input"))
+ .projection(Projection.of(ColumnRef.of("id")))
+ .filters(List.of(Filter.isNotNull(ColumnRef.of("id"))))
+ .limit(1);
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadResultTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadResultTest.java
new file mode 100644
index 00000000..61bae69d
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ReadResultTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+import java.net.URI;
+import java.util.EnumSet;
+import java.util.List;
+import org.junit.Test;
+
+public class ReadResultTest {
+ private final Schema schema =
+ new Schema(List.of(new Field("id",
ColumnType.of(ColumnType.Kind.INT64), false)));
+ private final ReadRequest request =
+ ReadRequest.builder(URI.create("file:///vertex/chunk0"))
+ .projection(Projection.of(ColumnRef.of("id")))
+ .limit(10)
+ .build();
+
+ @Test
+ public void carriesTheCursorAndReportItWasBuiltWith() {
+ BatchCursor cursor = new ListBatchCursor(schema, List.of());
+ ReadReport report =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
EnumSet.of(ReadCapability.LIMIT));
+
+ ReadResult result = new ReadResult(request, cursor, report);
+
+ assertSame(cursor, result.cursor());
+ assertSame(report, result.report());
+ }
+
+ @Test
+ public void refusesAReportThatLeavesARequestedHintUnaccounted() {
+ BatchCursor cursor = new ListBatchCursor(schema, List.of());
+ ReadReport partial =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
+ EnumSet.noneOf(ReadCapability.class));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> new ReadResult(request,
cursor, partial));
+ }
+
+ @Test
+ public void refusesAReportThatClaimsAHintNobodyRequested() {
+ BatchCursor cursor = new ListBatchCursor(schema, List.of());
+ ReadReport extra =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION,
ReadCapability.FILTER),
+ EnumSet.of(ReadCapability.LIMIT));
+
+ assertThrows(IllegalArgumentException.class, () -> new
ReadResult(request, cursor, extra));
+ }
+
+ @Test
+ public void refusesMissingParts() {
+ BatchCursor cursor = new ListBatchCursor(schema, List.of());
+ ReadReport report =
+ new ReadReport(
+ EnumSet.of(ReadCapability.PROJECTION),
EnumSet.of(ReadCapability.LIMIT));
+
+ assertThrows(NullPointerException.class, () -> new ReadResult(null,
cursor, report));
+ assertThrows(NullPointerException.class, () -> new ReadResult(request,
null, report));
+ assertThrows(NullPointerException.class, () -> new ReadResult(request,
cursor, null));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/RowRangeTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/RowRangeTest.java
new file mode 100644
index 00000000..3ece8998
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/RowRangeTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.graphar.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+public class RowRangeTest {
+ @Test
+ public void keepsTheHalfOpenBoundsItWasGiven() {
+ RowRange range = new RowRange(10, 25);
+
+ assertEquals(10, range.startInclusive());
+ assertEquals(25, range.endExclusive());
+ }
+
+ @Test
+ public void acceptsARangeThatSelectsNoRows() {
+ RowRange empty = new RowRange(7, 7);
+
+ assertEquals(empty.startInclusive(), empty.endExclusive());
+ }
+
+ @Test
+ public void comparesByBothBounds() {
+ assertEquals(new RowRange(1, 4), new RowRange(1, 4));
+ assertEquals(new RowRange(1, 4).hashCode(), new RowRange(1,
4).hashCode());
+ assertNotEquals(new RowRange(1, 4), new RowRange(1, 5));
+ }
+
+ @Test
+ public void refusesANegativeStartOrAnEndBeforeTheStart() {
+ assertThrows(IllegalArgumentException.class, () -> new RowRange(-1,
5));
+ assertThrows(IllegalArgumentException.class, () -> new RowRange(5, 4));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]