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]

Reply via email to